Bor create post (#9393)
* Initial setup for new keyboard * fix the offset calculation onMove by adding isKeyboardFullyOpened * Done with the keyboard handling implementation * Handled keyboard focus and blured state using context * Added default height for input container * Android support * Tablet state handling * Fix for refreshing offset in list * Created a default context for mention post list * Fix linter errors * Fix tests * Minor * Fix the height issue for tablet view * Review comments * Dependency fix * Reveiw comment * keyboard animation only enabled with screen on top navigation * added tests * Added extra keyboard component for emoji picker (#9328) * Added extra keyboard component * handled swipe geature for extra keyboard * scroll to bottom visible on emoji picker * Check for stale event for keyboard geature area * fix keyboard stale event for mid-gesture change swipe direction * Closing emoji picker when message priority is opened * changing to thread view closes emoji picker * Close emoij picker and keyboard when attachment icons are clicked * handle android emoji picker behaviour * Remove emoji picker code * fix tests * Address reviev comments * Test fixes * usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag * File attachments option in bottom sheet quick action (#9331) * Added extra keyboard component * handled swipe geature for extra keyboard * File attachments option in bottom sheet quick action * Updated tests * i18n * fix test * Added tests * Review comment * fix tests * Integrated Emoji picker to Extra keyboard component. (#9339) * Added extra keyboard component * handled swipe geature for extra keyboard * scroll to bottom visible on emoji picker * Fix the post input container height change issue * Added emoji picker with search functionality * keyboard height not recorded fallback to default height, set search to false closing emoji picker * fix search funcitonality in android * scroll to end bottom dismissed with pressed * Fixed the scroll issue for android * fix the flickering post input issue * Wired up the emoji picker * Added keyboard and spaceback in emoji picker * intl extract * initial cursor position and simple calculation review comment * separated grapheme to utils file * Review comments * nitpick * Fix the bottom safe area and navigation stack restore fix * Fix test * disabled emoji picker in edit post screen * change the name of the variable to name it clarier * fix the tab gap issue in andriod * disabled the emoji skin tone change on andriod * fix the input container jump issue * fix the failing test * UX review * added white space after the attachment icon * When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open * back bottom closes emoji picker and opening keyboard jump fix * reverted code * fixed the flickering issue of input and scroll position fix * Added BoR button * Fix the gap issue in thread * fix the warning reaminated issue when opening a channel * Fix the extra space issue between input and emoij picker * Minor fix for extra space between input and emoji picker * WIP * Fix thread view bottom safe area and gap between keyboard and searched emojis * WIP * refactor: make borConfig optional in BoRAction component * refactor: remove optional borConfig and simplify state initialization * Fix the keyboard issue in tablet * Fix the warning issue react-native-keyboard-controller * Fix tests * Fix the tablet search hight issue * Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink * WIP * Successfully toggeled bor status * Displayed bor chip in draft editor * Send bor post * Displayed bor chip in draft editor with wrapping * Added read receipt * Bottom sheet jump issue resolved * Search do not close after selecting emoji in search list * Added the bottom inset height in search emoji for android * integrated bor receipt count * fix buid issue * Added margin * Handled updating receipts on sync * test: add comprehensive tests for updateDraftBoRConfig * test: add initial test file for burn on read label component * test: add tests for BoRLabel component * test: update BoRLabel tests to use toBeVisible and remove formatTime mock * test: update BoR label tests to use renderWithIntl * refactor: remove getBaseProps and specify props inline in BoRLabel tests * refactor: improve test component formatting for readability * test: add validation for BoRLabel time display formats * test: add comprehensive tests for Burst on Read (BoR) functionality * WIP * test: add unit tests for BOR quick action component * test: add comprehensive tests for BoR quick action component * test: add missing props to QuickActions test setup * test: add tests for BoR quick action rendering * test: mock BoRQuickAction component for testing * test: add comprehensive tests for observeIsBoREnabled function * test: add initial test file for BOR read receipts screen * test: add comprehensive tests for BORReadReceipts component * test: add test cases for BOR read receipts display * test: add comprehensive tests for humanReadable format in formatTime * Added tests * lint fix * cleanup * cleanup * cleanup * cleanup * cleanup * i18n fix * Fix the cancel state to emoji picker * Prevented displaying BoR quick action in threads * Fixed a test * lint fix * review fixes * review fixes * i18n fix * fixed tests * review fixes * lint fix * restored podfile * Fixed WS event handling for receiving bor post receipnt * Fixed a test * fixed TS * Implemented burn post now for sender and receiver (#9401) * Implemented burn post now for sender and receiver * test: add tests for burnPostNow function * test: add test for delete post option * test: add comprehensive tests for DeletePostOption component * Added tests * Lint fix * minor improvemenmts * lint fix * reset unintended changes * reset unintended changes * Bor delete when read by all (#9407) * WIP * Updated for all read * cleanup * test: add comprehensive tests for handleBoRPostAllRevealed * test: add tests for BOR events in websocket event handler * feat: add comprehensive tests for shouldUpdateForBoRPost function * Added tests * Lint fix * fixed TS * fixed padding * UX fixes --------- Co-authored-by: Rajat Dabade <a-rajat.dabade@mattermost.com> Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com> Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
This commit is contained in:
parent
fe621047ce
commit
04de7b9745
51 changed files with 2016 additions and 100 deletions
|
|
@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
|
|||
import {Navigation, Screens} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {goToScreen, popTo} from '@screens/navigation';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
addFilesToDraft,
|
||||
removeDraft,
|
||||
updateDraftPriority,
|
||||
updateDraftBoRConfig,
|
||||
updateDraftMarkdownImageMetadata,
|
||||
} from './draft';
|
||||
|
||||
|
|
@ -405,3 +407,47 @@ describe('updateDraftMarkdownImageMetadata', () => {
|
|||
expect(result.draft.metadata?.images?.image1).toEqual(postImageData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDraftBoRConfig', () => {
|
||||
const postBoRConfig: PostBoRConfig = {
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
};
|
||||
|
||||
it('handle not found database', async () => {
|
||||
const result = await updateDraftBoRConfig('foo', channelId, '', postBoRConfig) as {draft: unknown; error: unknown};
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handle no draft', async () => {
|
||||
const models = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as DraftModel[];
|
||||
expect(models).toBeDefined();
|
||||
expect(models.length).toBe(1);
|
||||
expect(models[0].metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled);
|
||||
expect(models[0].metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds);
|
||||
expect(models[0].type).toBe(PostTypes.BURN_ON_READ);
|
||||
});
|
||||
|
||||
it('update draft BoR config with enabled true', async () => {
|
||||
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
|
||||
|
||||
const result = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as {draft: DraftModel; error: unknown};
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.draft).toBeDefined();
|
||||
expect(result.draft.metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled);
|
||||
expect(result.draft.metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds);
|
||||
expect(result.draft.type).toBe('burn_on_read');
|
||||
});
|
||||
|
||||
it('update draft BoR config with enabled false', async () => {
|
||||
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
|
||||
|
||||
const disabledBoRConfig = {...postBoRConfig, enabled: false};
|
||||
const result = await updateDraftBoRConfig(serverUrl, channelId, '', disabledBoRConfig) as {draft: DraftModel; error: unknown};
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.draft).toBeDefined();
|
||||
expect(result.draft.metadata?.borConfig?.enabled).toBe(false);
|
||||
expect(result.draft.type).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {Image} from 'expo-image';
|
|||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {Navigation, Screens} from '@constants';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getDraft} from '@queries/servers/drafts';
|
||||
import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
|
|
@ -265,6 +266,53 @@ export async function updateDraftPriority(serverUrl: string, channelId: string,
|
|||
}
|
||||
}
|
||||
|
||||
export async function updateDraftBoRConfig(serverUrl: string, channelId: string, rootId: string, postBoRConfig: PostBoRConfig, prepareRecordsOnly = false) {
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const draft = await getDraft(database, channelId, rootId);
|
||||
if (!draft) {
|
||||
const newDraft: Draft = {
|
||||
channel_id: channelId,
|
||||
root_id: rootId,
|
||||
update_at: Date.now(),
|
||||
metadata: {
|
||||
borConfig: postBoRConfig,
|
||||
},
|
||||
};
|
||||
|
||||
if (postBoRConfig.enabled) {
|
||||
newDraft.type = PostTypes.BURN_ON_READ;
|
||||
} else {
|
||||
newDraft.type = '';
|
||||
}
|
||||
|
||||
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
|
||||
}
|
||||
|
||||
draft?.prepareUpdate((d) => {
|
||||
d.metadata = {
|
||||
...d.metadata,
|
||||
borConfig: postBoRConfig,
|
||||
};
|
||||
|
||||
if (postBoRConfig.enabled) {
|
||||
d.type = PostTypes.BURN_ON_READ;
|
||||
} else {
|
||||
d.type = '';
|
||||
}
|
||||
});
|
||||
|
||||
if (!prepareRecordsOnly) {
|
||||
await operator.batchRecords([draft], 'updateDraftBoRConfig');
|
||||
}
|
||||
|
||||
return {draft};
|
||||
} catch (error) {
|
||||
logError('Failed updateDraftBoRConfig', error);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDraftMarkdownImageMetadata({
|
||||
serverUrl,
|
||||
channelId,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import DatabaseManager from '@database/manager';
|
|||
import PostModel from '@database/models/server/post';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import TestHelper from '@test/test_helper';
|
||||
import {getFullErrorMessage} from '@utils/errors';
|
||||
|
||||
import {
|
||||
createPost,
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
fetchPostById,
|
||||
fetchSavedPosts,
|
||||
fetchPinnedPosts,
|
||||
burnPostNow,
|
||||
} from './post';
|
||||
import * as PostAuxilaryFunctions from './post.auxiliary';
|
||||
|
||||
|
|
@ -94,6 +96,7 @@ const mockClient = {
|
|||
pinPost: jest.fn(),
|
||||
unpinPost: jest.fn(),
|
||||
deletePost: jest.fn(),
|
||||
burnPostNow: jest.fn(),
|
||||
getChannel: jest.fn((_channelId: string) => ({id: _channelId, name: 'channel1', creatorId: user1.id, total_msg_count: 100})),
|
||||
getChannelMember: jest.fn((_channelId: string, userId: string) => ({id: userId + '-' + _channelId, user_id: userId, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})),
|
||||
getMyChannelMember: jest.fn((_channelId: string) => ({id: user1.id + '-' + _channelId, user_id: user1.id, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})),
|
||||
|
|
@ -382,6 +385,51 @@ describe('create, update & delete posts', () => {
|
|||
expect(result.post).toBeDefined();
|
||||
});
|
||||
|
||||
it('burnPostNow - handle error', async () => {
|
||||
mockClient.burnPostNow.mockImplementationOnce(jest.fn(throwFunc));
|
||||
const result = await burnPostNow('foo', {} as PostModel);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('burnPostNow - base case', async () => {
|
||||
mockClient.burnPostNow.mockReset();
|
||||
const borPost = TestHelper.fakePost({channel_id: channelId, id: 'bor_postid1', user_id: user1.id, type: 'burn_on_read'});
|
||||
|
||||
const postModels = await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [borPost.id],
|
||||
posts: [borPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// verify post exists in database before burn
|
||||
const {database} = operator;
|
||||
const existingPost = await database.get('Post').find(borPost.id);
|
||||
expect(existingPost).toBeDefined();
|
||||
|
||||
const result = await burnPostNow(serverUrl, postModels[0] as PostModel);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.post).toBeDefined();
|
||||
|
||||
// Verify that the post is deleted from the database
|
||||
try {
|
||||
await database.get('Post').find(borPost.id);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
expect(getFullErrorMessage(error)).toBe('Record Post#bor_postid1 not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('burnPostNow - returns error for non-BoR posts', async () => {
|
||||
const result = await burnPostNow('foo', {} as PostModel);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error).toBe('Post is not a Burn-on-Read post');
|
||||
});
|
||||
|
||||
it('deletePost - system post', async () => {
|
||||
const postModels = await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers
|
|||
import {queryAllUsers} from '@queries/servers/user';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import {setFetchingThreadState} from '@store/fetching_thread_store';
|
||||
import {isBoRPost} from '@utils/bor';
|
||||
import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers';
|
||||
import {getFullErrorMessage, isServerError} from '@utils/errors';
|
||||
import {hasArrayChanged} from '@utils/helpers';
|
||||
|
|
@ -873,6 +874,24 @@ export const deletePost = async (serverUrl: string, postToDelete: PostModel | Po
|
|||
}
|
||||
};
|
||||
|
||||
export const burnPostNow = async (serverUrl: string, postToBurn: PostModel | Post) => {
|
||||
if (!isBoRPost(postToBurn)) {
|
||||
return {error: 'Post is not a Burn-on-Read post'};
|
||||
}
|
||||
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
await client.burnPostNow(postToBurn.id);
|
||||
|
||||
const post = await removePost(serverUrl, postToBurn);
|
||||
return {post};
|
||||
} catch (error) {
|
||||
logDebug('error on burnPostNow', getFullErrorMessage(error));
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const markPostAsUnread = async (serverUrl: string, postId: string) => {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
|
|
|
|||
|
|
@ -3,16 +3,21 @@
|
|||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts';
|
||||
import {ActionType} from '@constants';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent} from './burn_on_read';
|
||||
import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent, handleBoRPostAllRevealed} from './burn_on_read';
|
||||
|
||||
import type {ServerDatabase} from '@typings/database/database';
|
||||
|
||||
jest.mock('@actions/websocket/posts');
|
||||
jest.mock('@queries/servers/post');
|
||||
jest.mock('@actions/local/post');
|
||||
jest.mock('@queries/servers/user');
|
||||
|
||||
const serverUrl = 'burnOnRead.test.com';
|
||||
|
||||
|
|
@ -23,6 +28,7 @@ describe('WebSocket Burn on Read Actions', () => {
|
|||
const mockedHandleNewPostEvent = jest.mocked(handleNewPostEvent);
|
||||
const mockedHandlePostEdited = jest.mocked(handlePostEdited);
|
||||
const mockedRemovePost = jest.mocked(removePost);
|
||||
const mockedGetCurrentUser = jest.mocked(getCurrentUser);
|
||||
|
||||
beforeEach(async () => {
|
||||
await DatabaseManager.init([serverUrl]);
|
||||
|
|
@ -57,10 +63,149 @@ describe('WebSocket Burn on Read Actions', () => {
|
|||
await handleBoRPostRevealedEvent(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalledWith(serverUrl, msg);
|
||||
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should merge recipients from message with existing post recipients', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithRecipients = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
recipients: ['user3', 'user4'],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithRecipients);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2', 'user3', 'user4']);
|
||||
});
|
||||
|
||||
it('should deduplicate recipients when merging', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithDuplicates = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
recipients: ['user2', 'user3'],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithDuplicates);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2', 'user3']);
|
||||
});
|
||||
|
||||
it('should handle existing post without recipients metadata', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithRecipients = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
recipients: ['user1', 'user2'],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithRecipients);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
it('should preserve existing post metadata when adding recipients', async () => {
|
||||
const postWithMetadata = TestHelper.fakePost({
|
||||
id: 'post1',
|
||||
channel_id: 'channel1',
|
||||
user_id: 'user1',
|
||||
create_at: 12345,
|
||||
message: 'hello',
|
||||
metadata: {embeds: [{type: 'permalink'} as PostEmbed]},
|
||||
});
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithMetadata = {
|
||||
data: {
|
||||
post: JSON.stringify(postWithMetadata),
|
||||
recipients: ['user2'],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithMetadata);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.embeds).toEqual([{type: 'permalink'}]);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
it('should handle empty recipients array in websocket message', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithEmptyRecipients = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
recipients: [],
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithEmptyRecipients);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
it('should handle undefined recipients in websocket message', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
const msgWithoutRecipients = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msgWithoutRecipients);
|
||||
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalled();
|
||||
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
|
||||
const updatedPost = JSON.parse(calledMsg.data.post);
|
||||
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
it('should handle malformed post data gracefully', async () => {
|
||||
const malformedMsg = {
|
||||
data: {
|
||||
|
|
@ -186,4 +331,115 @@ describe('WebSocket Burn on Read Actions', () => {
|
|||
expect(mockedRemovePost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleBoRPostAllRevealed', () => {
|
||||
const currentUser = TestHelper.fakeUserModel({id: 'user1'});
|
||||
const burnOnReadPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
userId: 'user1',
|
||||
});
|
||||
|
||||
const msg = {
|
||||
data: {
|
||||
post_id: 'post1',
|
||||
sender_expire_at: 67890,
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockOperator = {
|
||||
database: {},
|
||||
handlePosts: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
DatabaseManager.serverDatabases[serverUrl] = {operator: mockOperator} as unknown as ServerDatabase;
|
||||
});
|
||||
|
||||
it('should update post with expire_at when user owns the burn-on-read post', async () => {
|
||||
const mockToApi = jest.fn().mockResolvedValue({
|
||||
id: 'post1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
user_id: 'user1',
|
||||
metadata: {},
|
||||
});
|
||||
burnOnReadPost.toApi = mockToApi;
|
||||
|
||||
mockedGetPostById.mockResolvedValue(burnOnReadPost);
|
||||
mockedGetCurrentUser.mockResolvedValue(currentUser);
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedGetCurrentUser).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(mockToApi).toHaveBeenCalled();
|
||||
expect(DatabaseManager.serverDatabases[serverUrl]?.operator?.handlePosts).toHaveBeenCalledWith({
|
||||
actionType: ActionType.POSTS.RECEIVED_NEW,
|
||||
order: ['post1'],
|
||||
posts: [{
|
||||
id: 'post1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
user_id: 'user1',
|
||||
metadata: {expire_at: 67890},
|
||||
}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
expect(result).toHaveProperty('post');
|
||||
expect(result!.post!.metadata.expire_at).toBe(67890);
|
||||
});
|
||||
|
||||
it('should return null when post does not exist locally', async () => {
|
||||
mockedGetPostById.mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when user does not own the post', async () => {
|
||||
const otherUsersBoRPost = TestHelper.fakePostModel({
|
||||
id: 'post1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
userId: 'otheruser1',
|
||||
});
|
||||
mockedGetPostById.mockResolvedValue(otherUsersBoRPost);
|
||||
mockedGetCurrentUser.mockResolvedValue(currentUser);
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedGetCurrentUser).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(DatabaseManager.serverDatabases[serverUrl]?.operator?.handlePosts).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing server database gracefully', async () => {
|
||||
const result = await handleBoRPostAllRevealed('invalid-server-url', msg);
|
||||
|
||||
expect(mockedGetPostById).not.toHaveBeenCalled();
|
||||
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing operator gracefully', async () => {
|
||||
DatabaseManager.serverDatabases[serverUrl] = {} as any;
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).not.toHaveBeenCalled();
|
||||
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return error object', async () => {
|
||||
mockedGetPostById.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await handleBoRPostAllRevealed(serverUrl, msg);
|
||||
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result!.error).toBeInstanceOf(Error);
|
||||
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@
|
|||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts';
|
||||
import {ActionType} from '@constants';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
import {isOwnBoRPost} from '@utils/bor';
|
||||
import {logError} from '@utils/log';
|
||||
|
||||
export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSocketMessage) {
|
||||
|
|
@ -17,6 +20,7 @@ export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSock
|
|||
|
||||
const {database} = operator;
|
||||
let post: Post;
|
||||
const recipients = msg.data.recipients || [];
|
||||
try {
|
||||
post = JSON.parse(msg.data.post);
|
||||
} catch {
|
||||
|
|
@ -24,7 +28,18 @@ export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSock
|
|||
}
|
||||
|
||||
const existingPost = await getPostById(database, post.id);
|
||||
|
||||
if (existingPost) {
|
||||
// Add the receipt to post metadata and update in websocket message so handlePostEdited can get
|
||||
// the updated list of recipients
|
||||
const existingRecipients = existingPost.metadata?.recipients || [];
|
||||
const updatedRecipients = Array.from(new Set([...existingRecipients, ...recipients]));
|
||||
post.metadata = {
|
||||
...post.metadata,
|
||||
recipients: updatedRecipients,
|
||||
};
|
||||
msg.data.post = JSON.stringify(post);
|
||||
|
||||
await handlePostEdited(serverUrl, msg);
|
||||
} else {
|
||||
await handleNewPostEvent(serverUrl, msg);
|
||||
|
|
@ -62,3 +77,41 @@ export async function handleBoRPostBurnedEvent(serverUrl: string, msg: WebSocket
|
|||
return {error};
|
||||
}
|
||||
}
|
||||
export async function handleBoRPostAllRevealed(serverUrl: string, msg: WebSocketMessage) {
|
||||
try {
|
||||
const postId = msg.data.post_id;
|
||||
const expireAt = msg.data.sender_expire_at;
|
||||
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {database} = operator;
|
||||
const post = await getPostById(database, postId);
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentUser = await getCurrentUser(database);
|
||||
if (!isOwnBoRPost(post, currentUser?.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// This converts PostModel to Post
|
||||
const updatedPost: Post = await post.toApi();
|
||||
updatedPost.metadata.expire_at = expireAt;
|
||||
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_NEW,
|
||||
order: [updatedPost.id],
|
||||
posts: [updatedPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
return {post: updatedPost};
|
||||
} catch (error) {
|
||||
logError('handleBoRPostAllRevealed could not handle websocket event for all revealed burn-on-read posts', error);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import * as bookmark from '@actions/local/channel_bookmark';
|
||||
import * as burnOnRead from '@actions/websocket/burn_on_read';
|
||||
import * as scheduledPost from '@actions/websocket/scheduled_post';
|
||||
import * as calls from '@calls/connection/websocket_event_handlers';
|
||||
import {WebsocketEvents} from '@constants';
|
||||
|
|
@ -36,6 +37,7 @@ jest.mock('@calls/connection/websocket_event_handlers');
|
|||
jest.mock('./group');
|
||||
jest.mock('@actions/local/channel_bookmark');
|
||||
jest.mock('@actions/websocket/scheduled_post');
|
||||
jest.mock('@actions/websocket/burn_on_read');
|
||||
jest.mock('@playbooks/actions/websocket/events');
|
||||
|
||||
describe('handleWebSocketEvent', () => {
|
||||
|
|
@ -526,6 +528,24 @@ describe('handleWebSocketEvent', () => {
|
|||
expect(scheduledPost.handleDeleteScheduledPost).toHaveBeenCalledWith(serverUrl, msg);
|
||||
});
|
||||
|
||||
it('should handle BOR_POST_REVEALED event', async () => {
|
||||
msg.event = WebsocketEvents.BOR_POST_REVEALED;
|
||||
await handleWebSocketEvent(serverUrl, msg);
|
||||
expect(burnOnRead.handleBoRPostRevealedEvent).toHaveBeenCalledWith(serverUrl, msg);
|
||||
});
|
||||
|
||||
it('should handle BOR_POST_BURNED event', async () => {
|
||||
msg.event = WebsocketEvents.BOR_POST_BURNED;
|
||||
await handleWebSocketEvent(serverUrl, msg);
|
||||
expect(burnOnRead.handleBoRPostBurnedEvent).toHaveBeenCalledWith(serverUrl, msg);
|
||||
});
|
||||
|
||||
it('should handle BURN_ON_READ_ALL_REVEALED event', async () => {
|
||||
msg.event = WebsocketEvents.BURN_ON_READ_ALL_REVEALED;
|
||||
await handleWebSocketEvent(serverUrl, msg);
|
||||
expect(burnOnRead.handleBoRPostAllRevealed).toHaveBeenCalledWith(serverUrl, msg);
|
||||
});
|
||||
|
||||
it('all messages should go through the playbooks handler', async () => {
|
||||
msg.event = WebsocketEvents.POST_DELETED; // any handled event should be enough
|
||||
await handleWebSocketEvent(serverUrl, msg);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
import {handleAgentPostUpdate} from '@agents/actions/websocket';
|
||||
|
||||
import * as bookmark from '@actions/local/channel_bookmark';
|
||||
import {handleBoRPostBurnedEvent, handleBoRPostRevealedEvent} from '@actions/websocket/burn_on_read';
|
||||
import {
|
||||
handleBoRPostAllRevealed,
|
||||
handleBoRPostBurnedEvent,
|
||||
handleBoRPostRevealedEvent,
|
||||
} from '@actions/websocket/burn_on_read';
|
||||
import * as scheduledPost from '@actions/websocket/scheduled_post';
|
||||
import * as calls from '@calls/connection/websocket_event_handlers';
|
||||
import {WebsocketEvents} from '@constants';
|
||||
|
|
@ -317,6 +321,9 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
|
|||
case WebsocketEvents.BOR_POST_BURNED:
|
||||
handleBoRPostBurnedEvent(serverUrl, msg);
|
||||
break;
|
||||
case WebsocketEvents.BURN_ON_READ_ALL_REVEALED:
|
||||
handleBoRPostAllRevealed(serverUrl, msg);
|
||||
break;
|
||||
}
|
||||
handlePlaybookEvents(serverUrl, msg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,16 @@ describe('ClientPosts', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('burnPostNow', async () => {
|
||||
const postId = 'post_id';
|
||||
await client.burnPostNow(postId);
|
||||
|
||||
expect(client.doFetch).toHaveBeenCalledWith(
|
||||
client.getPostRoute(postId) + '/burn',
|
||||
{method: 'delete'},
|
||||
);
|
||||
});
|
||||
|
||||
test('getPostThread', async () => {
|
||||
const postId = 'post_id';
|
||||
const options = {fetchThreads: true, collapsedThreads: false, collapsedThreadsExtended: false, direction: 'up'} as FetchPaginatedThreadOptions;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface ClientPostsMix {
|
|||
getPost: (postId: string, groupLabel?: RequestGroupLabel) => Promise<Post>;
|
||||
patchPost: (postPatch: Partial<Post> & {id: string}) => Promise<Post>;
|
||||
deletePost: (postId: string) => Promise<any>;
|
||||
burnPostNow: (postId: string) => Promise<any>;
|
||||
getPostThread: (postId: string, options: FetchPaginatedThreadOptions, groupLabel?: RequestGroupLabel) => Promise<PostResponse>;
|
||||
getPosts: (channelId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean, groupLabel?: RequestGroupLabel) => Promise<PostResponse>;
|
||||
getPostsSince: (channelId: string, since: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean, groupLabel?: RequestGroupLabel) => Promise<PostResponse>;
|
||||
|
|
@ -73,6 +74,13 @@ const ClientPosts = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
|
|||
);
|
||||
};
|
||||
|
||||
burnPostNow = async (postId: string) => {
|
||||
return this.doFetch(
|
||||
`${this.getPostRoute(postId)}/burn`,
|
||||
{method: 'delete'},
|
||||
);
|
||||
};
|
||||
|
||||
getPostThread = (postId: string, options: FetchPaginatedThreadOptions, groupLabel?: RequestGroupLabel) => {
|
||||
const {
|
||||
fetchThreads = true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import Tag from '@components/tag';
|
||||
|
|
@ -13,12 +13,15 @@ type Props = {
|
|||
}
|
||||
|
||||
export default function BoRLabel({durationSeconds, id}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
const intl = useIntl();
|
||||
|
||||
const message = formatMessage({
|
||||
id: 'burn_on_read.label.title',
|
||||
defaultMessage: 'BURN ON READ ({duration})',
|
||||
}, {duration: formatTime(durationSeconds, true)});
|
||||
const message = useMemo(() => {
|
||||
const duration = formatTime(durationSeconds, true, intl);
|
||||
return intl.formatMessage({
|
||||
id: 'burn_on_read.label.title',
|
||||
defaultMessage: 'BURN ON READ ({duration})',
|
||||
}, {duration});
|
||||
}, [durationSeconds, intl]);
|
||||
|
||||
return (
|
||||
<Tag
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {fileMaxWarning, fileSizeWarning, uploadDisabledWarning} from '@utils/fil
|
|||
import SendHandler from '../send_handler';
|
||||
|
||||
import type {ErrorHandlers} from '@typings/components/upload_error_handlers';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
testID?: string;
|
||||
|
|
@ -29,6 +30,7 @@ type Props = {
|
|||
updateValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
value: string;
|
||||
setIsFocused: (isFocused: boolean) => void;
|
||||
location?: AvailableScreens;
|
||||
}
|
||||
|
||||
const emptyFileList: FileInfo[] = [];
|
||||
|
|
@ -49,6 +51,7 @@ export default function DraftHandler(props: Props) {
|
|||
updateValue,
|
||||
value,
|
||||
setIsFocused,
|
||||
location,
|
||||
} = props;
|
||||
|
||||
const serverUrl = useServerUrl();
|
||||
|
|
@ -135,6 +138,7 @@ export default function DraftHandler(props: Props) {
|
|||
updatePostInputTop={updatePostInputTop}
|
||||
updateValue={updateValue}
|
||||
setIsFocused={setIsFocused}
|
||||
location={location}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act} from '@testing-library/react-hooks';
|
||||
import {fireEvent} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import {Screens} from '@constants';
|
||||
import {License, Screens} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {PostPriorityType} from '@constants/post';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {openAsBottomSheet} from '@screens/navigation';
|
||||
|
|
@ -40,6 +42,7 @@ describe('DraftInput', () => {
|
|||
currentUserId: 'currentUserId',
|
||||
postPriority: {priority: ''} as PostPriority,
|
||||
updatePostPriority: jest.fn(),
|
||||
updatePostBoRStatus: jest.fn(),
|
||||
persistentNotificationInterval: 0,
|
||||
persistentNotificationMaxRecipients: 0,
|
||||
updateCursorPosition: jest.fn(),
|
||||
|
|
@ -55,6 +58,7 @@ describe('DraftInput', () => {
|
|||
updatePostInputTop: jest.fn(),
|
||||
setIsFocused: jest.fn(),
|
||||
scheduledPostsEnabled: true,
|
||||
location: Screens.CHANNEL,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -210,4 +214,117 @@ describe('DraftInput', () => {
|
|||
expect(baseProps.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BoR (Burn on Read) Functionality', () => {
|
||||
|
||||
it('renders with BoR config disabled by default', () => {
|
||||
const {getByTestId, queryByTestId} = renderWithEverything(<DraftInput {...baseProps}/>, {database});
|
||||
|
||||
// Component should render successfully with BoR config
|
||||
const container = getByTestId('draft_input');
|
||||
expect(container).toBeVisible();
|
||||
|
||||
expect(queryByTestId('bor_label')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('passes BoR config to QuickActions component', () => {
|
||||
const borConfig = {
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
} as PostBoRConfig;
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
postBoRConfig: borConfig,
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithEverything(<DraftInput {...props}/>, {database});
|
||||
expect(getByTestId('bor_label')).toBeVisible();
|
||||
expect(getByTestId('bor_label')).toHaveTextContent('BURN ON READ (5m)');
|
||||
});
|
||||
|
||||
it('calls updatePostBoRStatus when BoR is toggled', async () => {
|
||||
await operator.handleConfigs({
|
||||
configs: [
|
||||
{id: 'EnableBurnOnRead', value: 'true'},
|
||||
{id: 'BuildEnterpriseReady', value: 'true'},
|
||||
{id: 'BurnOnReadDurationSeconds', value: '300'},
|
||||
{id: 'BurnOnReadMaximumTimeToLiveSeconds', value: '3600'},
|
||||
],
|
||||
configsToDelete: [],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await operator.handleSystem({
|
||||
systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const updatePostBoRStatusMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
updatePostBoRStatus: updatePostBoRStatusMock,
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithEverything(<DraftInput {...props}/>, {database});
|
||||
const borQuickAction = getByTestId('draft_input.quick_actions.bor_action');
|
||||
expect(borQuickAction).toBeVisible();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(borQuickAction);
|
||||
});
|
||||
|
||||
expect(updatePostBoRStatusMock).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Header component with BoR config', () => {
|
||||
const borConfig = {
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
} as PostBoRConfig;
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
postBoRConfig: borConfig,
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithEverything(<DraftInput {...props}/>, {database});
|
||||
|
||||
// The Header component should receive the BoR config
|
||||
const container = getByTestId('draft_input');
|
||||
expect(container).toBeVisible();
|
||||
});
|
||||
|
||||
it('maintains BoR config state during message sending', async () => {
|
||||
const borConfig = {
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
} as PostBoRConfig;
|
||||
|
||||
const props = {
|
||||
...baseProps,
|
||||
postBoRConfig: borConfig,
|
||||
value: 'test message',
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithEverything(<DraftInput {...props}/>, {database});
|
||||
|
||||
// Send message
|
||||
fireEvent.press(getByTestId('draft_input.send_action.send.button'));
|
||||
|
||||
// Verify sendMessage was called
|
||||
expect(baseProps.sendMessage).toHaveBeenCalled();
|
||||
|
||||
// BoR config should remain unchanged
|
||||
expect(props.postBoRConfig?.enabled).toBe(true);
|
||||
expect(props.postBoRConfig?.borDurationSeconds).toBe(300);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import Uploads from '../uploads';
|
|||
|
||||
import Header from './header';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
export type Props = {
|
||||
testID?: string;
|
||||
channelId: string;
|
||||
|
|
@ -32,10 +34,13 @@ export type Props = {
|
|||
rootId?: string;
|
||||
currentUserId: string;
|
||||
canShowPostPriority?: boolean;
|
||||
location?: AvailableScreens;
|
||||
|
||||
// Post Props
|
||||
postPriority: PostPriority;
|
||||
postBoRConfig?: PostBoRConfig;
|
||||
updatePostPriority: (postPriority: PostPriority) => void;
|
||||
updatePostBoRStatus: (config: PostBoRConfig) => void;
|
||||
persistentNotificationInterval: number;
|
||||
persistentNotificationMaxRecipients: number;
|
||||
|
||||
|
|
@ -128,10 +133,13 @@ function DraftInput({
|
|||
updatePostInputTop,
|
||||
postPriority,
|
||||
updatePostPriority,
|
||||
updatePostBoRStatus,
|
||||
persistentNotificationInterval,
|
||||
persistentNotificationMaxRecipients,
|
||||
setIsFocused,
|
||||
scheduledPostsEnabled,
|
||||
postBoRConfig,
|
||||
location,
|
||||
}: Props) {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
|
|
@ -216,6 +224,7 @@ function DraftInput({
|
|||
<Header
|
||||
noMentionsError={noMentionsError}
|
||||
postPriority={postPriority}
|
||||
postBoRConfig={postBoRConfig}
|
||||
/>
|
||||
<PostInput
|
||||
testID={postInputTestID}
|
||||
|
|
@ -248,7 +257,10 @@ function DraftInput({
|
|||
postPriority={postPriority}
|
||||
updatePostPriority={updatePostPriority}
|
||||
canShowPostPriority={canShowPostPriority}
|
||||
postBoRConfig={postBoRConfig}
|
||||
updatePostBoRStatus={updatePostBoRStatus}
|
||||
focus={focus}
|
||||
location={location}
|
||||
/>
|
||||
<SendAction
|
||||
testID={sendActionTestID}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import React from 'react';
|
||||
import {Platform, View} from 'react-native';
|
||||
|
||||
import BoRLabel from '@components/burn_on_read_label';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import PostPriorityLabel from '@components/post_priority/post_priority_label';
|
||||
|
|
@ -14,6 +15,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
|
|||
type Props = {
|
||||
postPriority: PostPriority;
|
||||
noMentionsError: boolean;
|
||||
postBoRConfig?: PostBoRConfig;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
|
|
@ -22,6 +24,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
alignItems: 'center',
|
||||
marginLeft: 12,
|
||||
gap: 7,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
error: {
|
||||
color: PostPriorityColors.URGENT,
|
||||
|
|
@ -37,13 +40,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
export default function DraftInputHeader({
|
||||
postPriority,
|
||||
noMentionsError,
|
||||
postBoRConfig,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const hasLabels = postPriority.priority !== '' || postPriority.requested_ack;
|
||||
const hasBoR = postBoRConfig && postBoRConfig.enabled;
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View style={[style.container, hasLabels ? style.paddingTopStyle : undefined]}>
|
||||
<View style={[style.container, (hasLabels || hasBoR) ? style.paddingTopStyle : undefined]}>
|
||||
{postPriority.priority && (
|
||||
<PostPriorityLabel label={postPriority.priority}/>
|
||||
)}
|
||||
|
|
@ -83,6 +88,11 @@ export default function DraftInputHeader({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{hasBoR &&
|
||||
<BoRLabel
|
||||
durationSeconds={postBoRConfig.borDurationSeconds}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ function PostDraft({
|
|||
updateValue={setValue}
|
||||
value={value}
|
||||
setIsFocused={setIsFocused}
|
||||
location={location}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import PressableOpacity from '@components/pressable_opacity';
|
||||
import {ICON_SIZE} from '@constants/post_draft';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import {style} from '../post_priority_action';
|
||||
|
||||
type Props = {
|
||||
testId?: string;
|
||||
postBoRConfig?: PostBoRConfig;
|
||||
updatePostBoRStatus: (config: PostBoRConfig) => void;
|
||||
defaultBorConfig: PostBoRConfig;
|
||||
}
|
||||
|
||||
export default function BoRQuickAction({testId, defaultBorConfig, postBoRConfig, updatePostBoRStatus}: Props) {
|
||||
const theme = useTheme();
|
||||
const iconColor = changeOpacity(theme.centerChannelColor, 0.64);
|
||||
|
||||
const toggleEnabled = useCallback(() => {
|
||||
const config = {...(postBoRConfig || defaultBorConfig)};
|
||||
config.enabled = !config.enabled;
|
||||
updatePostBoRStatus(config);
|
||||
}, [defaultBorConfig, postBoRConfig, updatePostBoRStatus]);
|
||||
|
||||
return (
|
||||
<PressableOpacity
|
||||
style={style.icon}
|
||||
onPress={toggleEnabled}
|
||||
>
|
||||
<CompassIcon
|
||||
testID={testId}
|
||||
name={'fire'}
|
||||
size={ICON_SIZE}
|
||||
color={iconColor}
|
||||
/>
|
||||
</PressableOpacity>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import {License} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import BoRQuickAction from './index';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
const SERVER_URL = 'https://appv1.mattermost.com';
|
||||
|
||||
// this is needed to when using the useServerUrl hook
|
||||
jest.mock('@context/server', () => ({
|
||||
useServerUrl: jest.fn(() => SERVER_URL),
|
||||
}));
|
||||
|
||||
describe('BoRQuickAction', () => {
|
||||
const baseProps = {
|
||||
testId: 'bor_quick_action',
|
||||
postBoRConfig: undefined,
|
||||
updatePostBoRStatus: jest.fn(),
|
||||
defaultBorConfig: {
|
||||
enabled: false,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
} as PostBoRConfig,
|
||||
};
|
||||
|
||||
let database: Database;
|
||||
let operator: ServerDataOperator;
|
||||
|
||||
beforeAll(async () => {
|
||||
const server = await TestHelper.setupServerDatabase(SERVER_URL);
|
||||
database = server.database;
|
||||
operator = server.operator;
|
||||
|
||||
await operator.handleConfigs({
|
||||
configs: [
|
||||
{id: 'EnableBurnOnRead', value: 'true'},
|
||||
{id: 'BuildEnterpriseReady', value: 'true'},
|
||||
{id: 'BurnOnReadDurationSeconds', value: '300'},
|
||||
{id: 'BurnOnReadMaximumTimeToLiveSeconds', value: '3600'},
|
||||
],
|
||||
configsToDelete: [],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await operator.handleSystem({
|
||||
systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await TestHelper.tearDown();
|
||||
NetworkManager.invalidateClient(SERVER_URL);
|
||||
});
|
||||
|
||||
it('renders the BoR quick action button', async () => {
|
||||
const {getByTestId} = renderWithEverything(<BoRQuickAction {...baseProps}/>, {database});
|
||||
|
||||
const borButton = getByTestId('bor_quick_action');
|
||||
expect(borButton).toBeVisible();
|
||||
});
|
||||
|
||||
it('toggles BoR status from disabled to enabled when pressed', async () => {
|
||||
const updatePostBoRStatusMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
updatePostBoRStatus: updatePostBoRStatusMock,
|
||||
postBoRConfig: {
|
||||
enabled: false,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
} as PostBoRConfig,
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithEverything(<BoRQuickAction {...props}/>, {database});
|
||||
|
||||
const borButton = getByTestId('bor_quick_action');
|
||||
fireEvent.press(borButton);
|
||||
|
||||
expect(updatePostBoRStatusMock).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles BoR status from enabled to disabled when pressed', async () => {
|
||||
const updatePostBoRStatusMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
updatePostBoRStatus: updatePostBoRStatusMock,
|
||||
postBoRConfig: {
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
} as PostBoRConfig,
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithEverything(<BoRQuickAction {...props}/>, {database});
|
||||
|
||||
const borButton = getByTestId('bor_quick_action');
|
||||
fireEvent.press(borButton);
|
||||
|
||||
expect(updatePostBoRStatusMock).toHaveBeenCalledWith({
|
||||
enabled: false,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses default config when postBoRConfig is undefined', async () => {
|
||||
const updatePostBoRStatusMock = jest.fn();
|
||||
const props = {
|
||||
...baseProps,
|
||||
updatePostBoRStatus: updatePostBoRStatusMock,
|
||||
postBoRConfig: undefined,
|
||||
};
|
||||
|
||||
const {getByTestId} = renderWithEverything(<BoRQuickAction {...props}/>, {database});
|
||||
|
||||
const borButton = getByTestId('bor_quick_action');
|
||||
fireEvent.press(borButton);
|
||||
|
||||
expect(updatePostBoRStatusMock).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
borDurationSeconds: 300,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
|
||||
import {observeBoRConfig} from '@queries/servers/post';
|
||||
|
||||
import BoRAction from './bor_quick_action';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
const defaultBorConfig = observeBoRConfig(database);
|
||||
return {
|
||||
defaultBorConfig,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(BoRAction));
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import React from 'react';
|
||||
|
||||
import {observeIsPostPriorityEnabled} from '@queries/servers/post';
|
||||
import {observeIsBoREnabled, observeIsPostPriorityEnabled} from '@queries/servers/post';
|
||||
import {observeCanUploadFiles} from '@queries/servers/security';
|
||||
import {observeMaxFileCount} from '@queries/servers/system';
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
|||
return {
|
||||
canUploadFiles,
|
||||
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),
|
||||
isBoREnabled: observeIsBoREnabled(database),
|
||||
maxFileCount,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ type Props = {
|
|||
updatePostPriority: (postPriority: PostPriority) => void;
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
export const style = StyleSheet.create({
|
||||
icon: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Screens} from '@constants';
|
||||
import {PostPriorityType} from '@constants/post';
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
|
||||
import QuickActions from './quick_actions';
|
||||
|
||||
jest.mock('@components/post_draft/quick_actions/bor_quick_action', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Quick Actions', () => {
|
||||
const baseProps: Parameters<typeof QuickActions>[0] = {
|
||||
isBoREnabled: false,
|
||||
updatePostBoRStatus: jest.fn(),
|
||||
testID: 'test-quick-actions',
|
||||
canUploadFiles: true,
|
||||
fileCount: 0,
|
||||
|
|
@ -23,6 +31,7 @@ describe('Quick Actions', () => {
|
|||
},
|
||||
updatePostPriority: jest.fn(),
|
||||
focus: jest.fn(),
|
||||
location: Screens.CHANNEL,
|
||||
};
|
||||
|
||||
describe('slash commands', () => {
|
||||
|
|
@ -170,4 +179,33 @@ describe('Quick Actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('BoR quick action', () => {
|
||||
it('should render BoR action when isBoREnabled is true', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isBoREnabled: true,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.bor_action')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not render BoR action when isBoREnabled is false', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isBoREnabled: false,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.bor_action')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render BoR action in threads', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
isBoREnabled: true,
|
||||
location: Screens.THREAD,
|
||||
};
|
||||
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
|
||||
expect(queryByTestId('test-quick-actions.bor_action')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,20 +4,27 @@
|
|||
import React from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import BoRQuickAction from '@components/post_draft/quick_actions/bor_quick_action';
|
||||
import {Screens} from '@constants';
|
||||
|
||||
import AttachmentAction from './attachment_quick_action';
|
||||
import EmojiAction from './emoji_quick_action';
|
||||
import InputAction from './input_quick_action';
|
||||
import PostPriorityAction from './post_priority_action';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
testID?: string;
|
||||
canUploadFiles: boolean;
|
||||
fileCount: number;
|
||||
isPostPriorityEnabled: boolean;
|
||||
isBoREnabled: boolean;
|
||||
canShowPostPriority?: boolean;
|
||||
canShowSlashCommands?: boolean;
|
||||
canShowEmojiPicker?: boolean;
|
||||
maxFileCount: number;
|
||||
location?: AvailableScreens;
|
||||
|
||||
// Draft Handler
|
||||
value: string;
|
||||
|
|
@ -25,6 +32,8 @@ type Props = {
|
|||
addFiles: (file: FileInfo[]) => void;
|
||||
postPriority: PostPriority;
|
||||
updatePostPriority: (postPriority: PostPriority) => void;
|
||||
postBoRConfig?: PostBoRConfig;
|
||||
updatePostBoRStatus?: (config: PostBoRConfig) => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +54,7 @@ export default function QuickActions({
|
|||
value,
|
||||
fileCount,
|
||||
isPostPriorityEnabled,
|
||||
isBoREnabled,
|
||||
canShowSlashCommands = true,
|
||||
canShowPostPriority,
|
||||
canShowEmojiPicker = true,
|
||||
|
|
@ -54,15 +64,20 @@ export default function QuickActions({
|
|||
postPriority,
|
||||
updatePostPriority,
|
||||
focus,
|
||||
updatePostBoRStatus,
|
||||
postBoRConfig,
|
||||
location,
|
||||
}: Props) {
|
||||
const atDisabled = value[value.length - 1] === '@';
|
||||
const slashDisabled = value.length > 0;
|
||||
const showBoRAction = isBoREnabled && updatePostBoRStatus && location === Screens.CHANNEL;
|
||||
|
||||
const atInputActionTestID = `${testID}.at_input_action`;
|
||||
const slashInputActionTestID = `${testID}.slash_input_action`;
|
||||
const emojiActionTestID = `${testID}.emoji_action`;
|
||||
const attachmentActionTestID = `${testID}.attachment_action`;
|
||||
const postPriorityActionTestID = `${testID}.post_priority_action`;
|
||||
const borPriorityActionTestID = `${testID}.bor_action`;
|
||||
|
||||
const uploadProps = {
|
||||
disabled: !canUploadFiles,
|
||||
|
|
@ -109,6 +124,13 @@ export default function QuickActions({
|
|||
updatePostPriority={updatePostPriority}
|
||||
/>
|
||||
)}
|
||||
{showBoRAction &&
|
||||
<BoRQuickAction
|
||||
testId={borPriorityActionTestID}
|
||||
postBoRConfig={postBoRConfig}
|
||||
updatePostBoRStatus={updatePostBoRStatus}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import {combineLatest, type Observable, of as of$, shareReplay} from 'rxjs';
|
||||
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {General, Permissions} from '@constants';
|
||||
import {DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft';
|
||||
|
|
@ -40,31 +40,34 @@ const enhanced = withObservables(['channelId', 'rootId', 'draftType'], (ownProps
|
|||
switchMap((u) => of$(u?.status === General.OUT_OF_OFFICE)),
|
||||
);
|
||||
|
||||
let postPriority;
|
||||
let metadata: Observable<PostMetadata>;
|
||||
if (draftType === DRAFT_TYPE_SCHEDULED) {
|
||||
postPriority = queryScheduledPost(database, channelId, rootId).observeWithColumns(['metadata']).pipe(
|
||||
switchMap(observeFirstScheduledPost),
|
||||
switchMap((d) => {
|
||||
if (!d?.metadata?.priority) {
|
||||
return of$(INITIAL_PRIORITY);
|
||||
}
|
||||
|
||||
return of$(d.metadata.priority);
|
||||
}),
|
||||
);
|
||||
metadata = queryScheduledPost(database, channelId, rootId).observeWithColumns(['metadata']).
|
||||
pipe(
|
||||
switchMap(observeFirstScheduledPost),
|
||||
switchMap((item) => of$(item?.metadata || {})),
|
||||
shareReplay({bufferSize: 1, refCount: true}),
|
||||
);
|
||||
} else {
|
||||
postPriority = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe(
|
||||
metadata = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe(
|
||||
switchMap(observeFirstDraft),
|
||||
switchMap((d) => {
|
||||
if (!d?.metadata?.priority) {
|
||||
return of$(INITIAL_PRIORITY);
|
||||
}
|
||||
|
||||
return of$(d.metadata.priority);
|
||||
}),
|
||||
switchMap((item) => of$(item?.metadata || {})),
|
||||
shareReplay({bufferSize: 1, refCount: true}),
|
||||
);
|
||||
}
|
||||
|
||||
const postPriority = metadata.pipe(
|
||||
switchMap((m) => of$(m?.priority || INITIAL_PRIORITY)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const postBoRConfig = metadata.pipe(
|
||||
switchMap((m) => {
|
||||
return of$(m?.borConfig || null);
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const enableConfirmNotificationsToChannel = observeConfigBooleanValue(database, 'EnableConfirmNotificationsToChannel');
|
||||
const maxMessageLength = observeConfigIntValue(database, 'MaxPostSize', MAX_MESSAGE_LENGTH_FALLBACK);
|
||||
const persistentNotificationInterval = observeConfigIntValue(database, 'PersistentNotificationIntervalMinutes');
|
||||
|
|
@ -104,6 +107,7 @@ const enhanced = withObservables(['channelId', 'rootId', 'draftType'], (ownProps
|
|||
persistentNotificationInterval,
|
||||
persistentNotificationMaxRecipients,
|
||||
postPriority,
|
||||
postBoRConfig,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import React, {useCallback} from 'react';
|
||||
|
||||
import {updateDraftPriority} from '@actions/local/draft';
|
||||
import {updateDraftBoRConfig, updateDraftPriority} from '@actions/local/draft';
|
||||
import SendDraft from '@components/draft_scheduled_post/draft_scheduled_post_actions/send_draft';
|
||||
import DraftInput from '@components/post_draft/draft_input/';
|
||||
import {PostPriorityType} from '@constants/post';
|
||||
|
|
@ -45,6 +45,7 @@ type Props = {
|
|||
persistentNotificationInterval: number;
|
||||
persistentNotificationMaxRecipients: number;
|
||||
postPriority: PostPriority;
|
||||
postBoRConfig?: PostBoRConfig;
|
||||
|
||||
draftType?: DraftType;
|
||||
postId?: string;
|
||||
|
|
@ -52,6 +53,7 @@ type Props = {
|
|||
channelDisplayName?: string;
|
||||
isFromDraftView?: boolean;
|
||||
draftReceiverUserName?: string;
|
||||
location?: AvailableScreens;
|
||||
}
|
||||
|
||||
export const INITIAL_PRIORITY = {
|
||||
|
|
@ -93,6 +95,8 @@ export default function SendHandler({
|
|||
isFromDraftView,
|
||||
draftType,
|
||||
postId,
|
||||
postBoRConfig,
|
||||
location,
|
||||
}: Props) {
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
|
|
@ -100,6 +104,10 @@ export default function SendHandler({
|
|||
updateDraftPriority(serverUrl, channelId, rootId, priority);
|
||||
}, [serverUrl, channelId, rootId]);
|
||||
|
||||
const handlePostBoRStatus = useCallback((config: PostBoRConfig) => {
|
||||
updateDraftBoRConfig(serverUrl, channelId, rootId, config);
|
||||
}, [channelId, rootId, serverUrl]);
|
||||
|
||||
const {handleSendMessage, canSend} = useHandleSendMessage({
|
||||
value,
|
||||
channelId,
|
||||
|
|
@ -115,6 +123,7 @@ export default function SendHandler({
|
|||
channelType,
|
||||
postPriority,
|
||||
clearDraft,
|
||||
postBoRConfig,
|
||||
});
|
||||
|
||||
if (isFromDraftView) {
|
||||
|
|
@ -166,10 +175,13 @@ export default function SendHandler({
|
|||
maxMessageLength={maxMessageLength}
|
||||
updatePostInputTop={updatePostInputTop}
|
||||
postPriority={postPriority}
|
||||
postBoRConfig={postBoRConfig}
|
||||
updatePostPriority={handlePostPriority}
|
||||
updatePostBoRStatus={handlePostBoRStatus}
|
||||
persistentNotificationInterval={persistentNotificationInterval}
|
||||
persistentNotificationMaxRecipients={persistentNotificationMaxRecipients}
|
||||
setIsFocused={setIsFocused}
|
||||
location={location}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {CHANNEL, THREAD} from '@constants/screens';
|
|||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {DEFAULT_LOCALE} from '@i18n';
|
||||
import {isOwnBoRPost, isUnrevealedBoRPost} from '@utils/bor';
|
||||
import {isUnrevealedBoRPost} from '@utils/bor';
|
||||
import {postUserDisplayName} from '@utils/post';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {ensureString} from '@utils/types';
|
||||
|
|
@ -101,9 +101,7 @@ const Header = (props: HeaderProps) => {
|
|||
const userIconOverride = ensureString(post.props?.override_icon_url);
|
||||
const usernameOverride = ensureString(post.props?.override_username);
|
||||
|
||||
const isUnrevealedPost = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]);
|
||||
const ownBoRPost = useMemo(() => isOwnBoRPost(post, currentUser?.id), [currentUser?.id, post]);
|
||||
const showBoRIcon = isUnrevealedPost || ownBoRPost;
|
||||
const showBoRIcon = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]);
|
||||
const borExpireAt = post.metadata?.expire_at;
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
|
|
@ -164,7 +162,7 @@ const Header = (props: HeaderProps) => {
|
|||
/>
|
||||
}
|
||||
{
|
||||
!showBoRIcon && Boolean(borExpireAt) &&
|
||||
Boolean(borExpireAt) &&
|
||||
<ExpiryTimer
|
||||
expiryTime={borExpireAt as number}
|
||||
onExpiry={onBoRPostExpiry}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,10 @@ const Post = ({
|
|||
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost, blurAndDismissKeyboard]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isBoRPost(post)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pressDetected.current = true;
|
||||
|
||||
KeyboardController.dismiss();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Pressable} from 'react-native-gesture-handler';
|
||||
import {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import type {StyleProp, ViewStyle} from 'react-native';
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ export default function PressableOpacity({children, onPress, style}: Props) {
|
|||
}, []);
|
||||
|
||||
const cancelPressOut = useCallback(() => {
|
||||
cancelOpacity.value = withTiming(1, {duration: 100});
|
||||
cancelOpacity.value = withTiming(1, {duration: 300});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -35,9 +35,11 @@ export default function PressableOpacity({children, onPress, style}: Props) {
|
|||
onPressIn={cancelPressIn}
|
||||
onPressOut={cancelPressOut}
|
||||
onPress={onPress}
|
||||
style={[style, cancelAnimatedStyle]}
|
||||
style={[style]}
|
||||
>
|
||||
{children}
|
||||
<Animated.View style={cancelAnimatedStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ const WebsocketEvents = {
|
|||
// Burn on Read
|
||||
BOR_POST_REVEALED: 'post_revealed',
|
||||
BOR_POST_BURNED: 'post_burned',
|
||||
BURN_ON_READ_ALL_REVEALED: 'burn_on_read_all_revealed',
|
||||
};
|
||||
|
||||
export default WebsocketEvents;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {shouldUpdateScheduledPostRecord} from '../comparators/scheduled_post';
|
|||
import {exportedForTest} from './post';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator/index';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel';
|
||||
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
|
||||
|
||||
|
|
@ -813,6 +814,247 @@ describe('*** Operator: Post Handlers tests ***', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('=> HandlePosts: should update burn-on-read post when its read receipts change', async () => {
|
||||
const spyOnProcessRecords = jest.spyOn(operator, 'processRecords');
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Create an unrevealed burn-on-read post
|
||||
const existingPost: Post = {
|
||||
id: 'bor_post_id',
|
||||
create_at: 1596032651747,
|
||||
update_at: 1596032651747,
|
||||
edit_at: 0,
|
||||
delete_at: 0,
|
||||
is_pinned: false,
|
||||
is_following: false,
|
||||
user_id: 'user_id',
|
||||
channel_id: 'channel_id',
|
||||
root_id: '',
|
||||
original_id: '',
|
||||
message: 'This is the revealed message',
|
||||
type: 'burn_on_read',
|
||||
props: {expire_at: now + 1000000},
|
||||
hashtags: '',
|
||||
pending_post_id: '',
|
||||
reply_count: 0,
|
||||
last_reply_at: 0,
|
||||
participants: null,
|
||||
metadata: {expire_at: now + 1000000},
|
||||
};
|
||||
|
||||
// First, create the existing post
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [existingPost.id],
|
||||
posts: [existingPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// Now create an updated version of the same post
|
||||
const updatedPost: Post = {
|
||||
...existingPost,
|
||||
metadata: {
|
||||
...existingPost.metadata,
|
||||
recipients: ['receipt_id_1'],
|
||||
},
|
||||
};
|
||||
|
||||
// Handle the updated post
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [updatedPost.id],
|
||||
posts: [updatedPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// Verify that processRecords was called with the shouldUpdate function that handles BoR posts
|
||||
expect(spyOnProcessRecords).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createOrUpdateRawValues: [updatedPost],
|
||||
shouldUpdate: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('=> HandlePosts: should update burn-on-read post when its read by all', async () => {
|
||||
const spyOnProcessRecords = jest.spyOn(operator, 'processRecords');
|
||||
const now = Date.now();
|
||||
|
||||
const existingPost: Post = {
|
||||
id: 'bor_post_id',
|
||||
create_at: 1596032651747,
|
||||
update_at: 1596032651747,
|
||||
edit_at: 0,
|
||||
delete_at: 0,
|
||||
is_pinned: false,
|
||||
is_following: false,
|
||||
user_id: 'user_id',
|
||||
channel_id: 'channel_id',
|
||||
root_id: '',
|
||||
original_id: '',
|
||||
message: 'This is the revealed message',
|
||||
type: 'burn_on_read',
|
||||
props: {expire_at: now + 1000000},
|
||||
hashtags: '',
|
||||
pending_post_id: '',
|
||||
reply_count: 0,
|
||||
last_reply_at: 0,
|
||||
participants: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
// First, create the existing post
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [existingPost.id],
|
||||
posts: [existingPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// Now create an updated version of the same post
|
||||
const updatedPost: Post = {
|
||||
...existingPost,
|
||||
metadata: {
|
||||
...existingPost.metadata,
|
||||
expire_at: now + 1000000,
|
||||
},
|
||||
};
|
||||
|
||||
// Handle the updated post
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [updatedPost.id],
|
||||
posts: [updatedPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// Verify that processRecords was called with the shouldUpdate function that handles BoR posts
|
||||
expect(spyOnProcessRecords).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createOrUpdateRawValues: [updatedPost],
|
||||
shouldUpdate: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('*** Operator: shouldUpdateForBoRPost tests ***', () => {
|
||||
const {shouldUpdateForBoRPost} = exportedForTest;
|
||||
|
||||
it('should return false for non-BoR posts', () => {
|
||||
const existingPost = {
|
||||
type: 'regular',
|
||||
metadata: {},
|
||||
} as unknown as PostModel;
|
||||
|
||||
const newPost = {
|
||||
type: 'regular',
|
||||
metadata: {},
|
||||
} as unknown as Post;
|
||||
|
||||
expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when only one post is BoR', () => {
|
||||
const existingPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {},
|
||||
} as PostModel;
|
||||
|
||||
const newPost = {
|
||||
type: 'regular',
|
||||
metadata: {},
|
||||
} as unknown as Post;
|
||||
|
||||
expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when BoR post gets revealed', () => {
|
||||
const existingPost = {
|
||||
type: 'burn_on_read',
|
||||
props: {expire_at: 123456789},
|
||||
metadata: {},
|
||||
} as unknown as PostModel;
|
||||
|
||||
const newPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {expire_at: 123456789},
|
||||
} as Post;
|
||||
|
||||
expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when BoR post recipients list changes', () => {
|
||||
const existingPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {recipients: ['user1']},
|
||||
} as PostModel;
|
||||
|
||||
const newPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {recipients: ['user1', 'user2']},
|
||||
} as Post;
|
||||
|
||||
expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when BoR post gets read by all (expire_at added)', () => {
|
||||
const existingPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {},
|
||||
} as PostModel;
|
||||
|
||||
const newPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {expire_at: 123456789},
|
||||
} as Post;
|
||||
|
||||
expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when BoR posts have no relevant changes', () => {
|
||||
const existingPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {recipients: ['user1'], expire_at: 123456789},
|
||||
} as PostModel;
|
||||
|
||||
const newPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {recipients: ['user1'], expire_at: 123456789},
|
||||
} as Post;
|
||||
|
||||
expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing recipients arrays', () => {
|
||||
const existingPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {},
|
||||
} as PostModel;
|
||||
|
||||
const newPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {recipients: ['user1']},
|
||||
} as Post;
|
||||
|
||||
expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle recipients array becoming empty', () => {
|
||||
const existingPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {recipients: ['user1']},
|
||||
} as PostModel;
|
||||
|
||||
const newPost = {
|
||||
type: 'burn_on_read',
|
||||
metadata: {recipients: []},
|
||||
} as unknown as Post;
|
||||
|
||||
expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('*** Operator: merge chunks ***', () => {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,23 @@ const mergePostInChannelChunks = async (newChunk: PostsInChannelModel, existingC
|
|||
|
||||
export const exportedForTest = {
|
||||
mergePostInChannelChunks,
|
||||
shouldUpdateForBoRPost,
|
||||
};
|
||||
|
||||
function shouldUpdateForBoRPost(e: PostModel, n: Post): boolean {
|
||||
const bothBoRPost = e.type === PostTypes.BURN_ON_READ && n.type === PostTypes.BURN_ON_READ;
|
||||
if (!bothBoRPost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const borPostGotRevealed = isUnrevealedBoRPost(e) && !isUnrevealedBoRPost(n);
|
||||
const borPostGotReadByAll = e.metadata?.expire_at === undefined && n.metadata?.expire_at !== undefined;
|
||||
|
||||
// Since a user can't un-see a BoR post, we consider an update if the recipients list length has changed
|
||||
const borRecipientsUpdated = (e.metadata?.recipients || []).length !== (n.metadata?.recipients || []).length;
|
||||
return borPostGotRevealed || borRecipientsUpdated || borPostGotReadByAll;
|
||||
}
|
||||
|
||||
const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(superclass: TBase) => class extends superclass {
|
||||
/**
|
||||
* handleScheduledPosts: Handler responsible for the Create/Update operations occurring the SchedulePost table from the 'Server' schema
|
||||
|
|
@ -367,8 +382,7 @@ const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
|
|||
tableName,
|
||||
fieldName: 'id',
|
||||
shouldUpdate: (e: PostModel, n: Post) => {
|
||||
const bothBoRPost = e.type === PostTypes.BURN_ON_READ && n.type === PostTypes.BURN_ON_READ;
|
||||
if (bothBoRPost && isUnrevealedBoRPost(e) && !isUnrevealedBoRPost(n)) {
|
||||
if (shouldUpdateForBoRPost(e, n)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export const transformDraftRecord = ({action, database, value}: TransformerArgs<
|
|||
draft.files = raw?.files ?? emptyFileInfo;
|
||||
draft.metadata = raw?.metadata ?? emptyPostMetadata;
|
||||
draft.updateAt = raw.update_at ?? Date.now();
|
||||
draft.type = raw.type ?? '';
|
||||
};
|
||||
|
||||
return prepareBaseRecord({
|
||||
|
|
|
|||
|
|
@ -657,4 +657,35 @@ describe('useHandleSendMessage', () => {
|
|||
expect(defaultProps.clearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle sending BoR message', async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
postBoRConfig: {
|
||||
enabled: true,
|
||||
borDurationSeconds: 60,
|
||||
borMaximumTimeToLiveSeconds: 3600,
|
||||
} as PostBoRConfig,
|
||||
};
|
||||
const {result} = renderHook(() => useHandleSendMessage(props), {wrapper});
|
||||
|
||||
expect(result.current.canSend).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleSendMessage();
|
||||
});
|
||||
|
||||
expect(createPost).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
channel_id: 'channel-id',
|
||||
message: 'test message',
|
||||
user_id: 'current-user',
|
||||
type: 'burn_on_read',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
expect(defaultProps.clearDraft).toHaveBeenCalled();
|
||||
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_LIST_SCROLL_TO_BOTTOM, Screens.CHANNEL);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ type Props = {
|
|||
channelIsArchived?: boolean;
|
||||
channelIsReadOnly?: boolean;
|
||||
deactivatedChannel?: boolean;
|
||||
postBoRConfig?: PostBoRConfig;
|
||||
}
|
||||
|
||||
export const useHandleSendMessage = ({
|
||||
|
|
@ -75,6 +76,7 @@ export const useHandleSendMessage = ({
|
|||
channelIsReadOnly,
|
||||
deactivatedChannel,
|
||||
clearDraft,
|
||||
postBoRConfig,
|
||||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
|
|
@ -125,6 +127,10 @@ export const useHandleSendMessage = ({
|
|||
};
|
||||
}
|
||||
|
||||
if (postBoRConfig?.enabled) {
|
||||
post.type = 'burn_on_read';
|
||||
}
|
||||
|
||||
let response: CreateResponse;
|
||||
if (schedulingInfo) {
|
||||
response = await createScheduledPost(serverUrl, scheduledPostFromPost(post, schedulingInfo, postPriority, postFiles));
|
||||
|
|
@ -165,7 +171,7 @@ export const useHandleSendMessage = ({
|
|||
|
||||
setSendingMessage(false);
|
||||
DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL);
|
||||
}, [files, currentUserId, channelId, rootId, value, postPriority, isFromDraftView, serverUrl, intl, canPost, channelIsArchived, channelIsReadOnly, deactivatedChannel, clearDraft]);
|
||||
}, [files, currentUserId, channelId, rootId, value, postPriority, postBoRConfig?.enabled, isFromDraftView, serverUrl, clearDraft, intl, canPost, channelIsArchived, channelIsReadOnly, deactivatedChannel]);
|
||||
|
||||
const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean, schedulingInfo?: SchedulingInfo) => {
|
||||
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, channelTimezoneCount, atHere);
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Database} from '@nozbe/watermelondb';
|
||||
import {firstValueFrom} from 'rxjs';
|
||||
|
||||
import {ActionType} from '@constants';
|
||||
import {ActionType, License} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import ServerDataOperator from '@database/operator/server_data_operator';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import {queryPostsWithPermalinkReferences} from './post';
|
||||
import {queryPostsWithPermalinkReferences, observeIsBoREnabled} from './post';
|
||||
|
||||
describe('Post Queries', () => {
|
||||
const serverUrl = 'post.test.com';
|
||||
|
|
@ -278,4 +280,91 @@ describe('Post Queries', () => {
|
|||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('observeIsBoREnabled', () => {
|
||||
it('should return true when both feature is enabled and license is valid', async () => {
|
||||
await operator.handleSystem({
|
||||
systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await operator.handleConfigs({
|
||||
configs: [
|
||||
{id: 'EnableBurnOnRead', value: 'true'},
|
||||
{id: 'BuildEnterpriseReady', value: 'true'},
|
||||
],
|
||||
configsToDelete: [],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(observeIsBoREnabled(database));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when feature is disabled but license is valid', async () => {
|
||||
await operator.handleSystem({
|
||||
systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await operator.handleConfigs({
|
||||
configs: [
|
||||
{id: 'EnableBurnOnRead', value: 'false'},
|
||||
],
|
||||
configsToDelete: [],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(observeIsBoREnabled(database));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when feature is enabled but license is insufficient', async () => {
|
||||
await operator.handleSystem({
|
||||
systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Enterprise}}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await operator.handleConfigs({
|
||||
configs: [
|
||||
{id: 'EnableBurnOnRead', value: 'true'},
|
||||
{id: 'BuildEnterpriseReady', value: 'true'},
|
||||
],
|
||||
configsToDelete: [],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(observeIsBoREnabled(database));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when both feature is disabled and license is insufficient', async () => {
|
||||
await operator.handleSystem({
|
||||
systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Professional}}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await operator.handleConfigs({
|
||||
configs: [
|
||||
{id: 'EnableBurnOnRead', value: 'false'},
|
||||
{id: 'BuildEnterpriseReady', value: 'true'},
|
||||
],
|
||||
configsToDelete: [],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(observeIsBoREnabled(database));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no config is set', async () => {
|
||||
await operator.handleSystem({
|
||||
systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(observeIsBoREnabled(database));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Database, Model, Q, Query} from '@nozbe/watermelondb';
|
||||
import {of as of$, combineLatestWith} from 'rxjs';
|
||||
import {of as of$, combineLatestWith, combineLatest} from 'rxjs';
|
||||
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
|
||||
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
import {SKU_SHORT_NAME} from '@constants/license';
|
||||
import {logDebug, logWarning} from '@utils/log';
|
||||
import {updatePermalinkMetadata} from '@utils/permalink_sync';
|
||||
|
||||
import {queryGroupsByNames} from './group';
|
||||
import {querySavedPostsPreferences} from './preference';
|
||||
import {getConfigValue, observeConfigBooleanValue} from './system';
|
||||
import {getConfigValue, observeConfigBooleanValue, observeConfigIntValue, observeIsMinimumLicenseTier} from './system';
|
||||
import {queryUsersByUsername, observeUser, observeCurrentUser} from './user';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
|
@ -20,6 +21,9 @@ import type PostsInThreadModel from '@typings/database/models/servers/posts_in_t
|
|||
|
||||
const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES;
|
||||
|
||||
const DEFAULT_BURN_ON_READ_DURATION_SECONDS = '600';
|
||||
const DEFAULT_BURN_ON_READ_MAXIMUM_TTL_SECONDS = '604800';
|
||||
|
||||
export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
|
||||
const preparedModels: Model[] = [post.prepareDestroyPermanently()];
|
||||
const relations: Array<Query<Model>> = [post.drafts, post.files, post.reactions];
|
||||
|
|
@ -254,6 +258,32 @@ export const observeIsPostPriorityEnabled = (database: Database) => {
|
|||
return observeConfigBooleanValue(database, 'PostPriority');
|
||||
};
|
||||
|
||||
export const observeIsBoREnabled = (database: Database) => {
|
||||
const featureEnabled = observeConfigBooleanValue(database, 'EnableBurnOnRead');
|
||||
const licenseValid = observeIsMinimumLicenseTier(database, SKU_SHORT_NAME.EnterpriseAdvanced);
|
||||
|
||||
return combineLatest([featureEnabled, licenseValid]).pipe(
|
||||
switchMap(([enabled, licensed]) => of$(enabled && licensed)),
|
||||
);
|
||||
};
|
||||
|
||||
export const observeBoRConfig = (database: Database) => {
|
||||
const borDurationSecondsObservable = observeConfigIntValue(database, 'BurnOnReadDurationSeconds');
|
||||
const maxBoRDurationSecondsStringObservable = observeConfigIntValue(database, 'BurnOnReadMaximumTimeToLiveSeconds');
|
||||
|
||||
// merge all observables and return single observable of object with both values
|
||||
return combineLatest([borDurationSecondsObservable, maxBoRDurationSecondsStringObservable]).pipe(
|
||||
switchMap(([borDurationSeconds, borMaximumTimeToLiveSeconds]) => {
|
||||
const borConfig = {
|
||||
enabled: false,
|
||||
borDurationSeconds: borDurationSeconds || parseInt(DEFAULT_BURN_ON_READ_DURATION_SECONDS, 10),
|
||||
borMaximumTimeToLiveSeconds: borMaximumTimeToLiveSeconds || parseInt(DEFAULT_BURN_ON_READ_MAXIMUM_TTL_SECONDS, 10),
|
||||
};
|
||||
return of$(borConfig);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const observeIsPostAcknowledgementsEnabled = (database: Database) => {
|
||||
return observeConfigBooleanValue(database, 'PostAcknowledgements');
|
||||
};
|
||||
|
|
|
|||
58
app/screens/post_options/bor_read_receipts/index.test.tsx
Normal file
58
app/screens/post_options/bor_read_receipts/index.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react-native';
|
||||
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import BORReadReceipts from './index';
|
||||
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
describe('BORReadReceipts', () => {
|
||||
let database: Database;
|
||||
beforeAll(async () => {
|
||||
const server = await TestHelper.setupServerDatabase();
|
||||
database = server.database;
|
||||
});
|
||||
|
||||
it('should render title and read receipt text with correct values', () => {
|
||||
renderWithEverything(
|
||||
<BORReadReceipts
|
||||
totalReceipts={5}
|
||||
readReceipts={3}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Burn-on-read message')).toBeVisible();
|
||||
expect(screen.getByText('Read by 3 of 5 recipients')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render with zero read receipts', () => {
|
||||
renderWithEverything(
|
||||
<BORReadReceipts
|
||||
totalReceipts={10}
|
||||
readReceipts={0}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Burn-on-read message')).toBeVisible();
|
||||
expect(screen.getByText('Read by 0 of 10 recipients')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render when all recipients have read', () => {
|
||||
renderWithEverything(
|
||||
<BORReadReceipts
|
||||
totalReceipts={7}
|
||||
readReceipts={7}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Burn-on-read message')).toBeVisible();
|
||||
expect(screen.getByText('Read by 7 of 7 recipients')).toBeVisible();
|
||||
});
|
||||
});
|
||||
63
app/screens/post_options/bor_read_receipts/index.tsx
Normal file
63
app/screens/post_options/bor_read_receipts/index.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
export const BOR_READ_RECEIPTS_HEIGHT = 54;
|
||||
|
||||
type Props = {
|
||||
totalReceipts: number;
|
||||
readReceipts: number;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
paddingBottom: 16,
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
height: BOR_READ_RECEIPTS_HEIGHT,
|
||||
},
|
||||
title: {
|
||||
...typography('Body', 50, 'SemiBold'),
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
},
|
||||
body: {
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function BORReadReceipts({totalReceipts, readReceipts}: Props) {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
testID='bor_read_receipts'
|
||||
>
|
||||
<FormattedText
|
||||
style={styles.title}
|
||||
id='mobile.burn_on_read.read_receipt.title'
|
||||
defaultMessage='Burn-on-read message'
|
||||
/>
|
||||
<FormattedText
|
||||
id='mobile.burn_on_read.read_receipt.text'
|
||||
defaultMessage='Read by {readReceipts} of {totalReceipts} recipients'
|
||||
values={{totalReceipts, readReceipts}}
|
||||
style={styles.body}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -164,4 +164,62 @@ describe('PostOptions', () => {
|
|||
expect(screen.queryByText('Mark as Unread')).not.toBeVisible();
|
||||
expect(screen.queryByText('Follow Message')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should show BOR read receipts for own BoR post', async () => {
|
||||
const ownBoRPost = TestHelper.fakePostModel({
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
channelId: TestHelper.basicChannel!.id,
|
||||
userId: TestHelper.basicUser!.id,
|
||||
message: 'This is my BoR post',
|
||||
metadata: {
|
||||
expire_at: Date.now() + 1000000,
|
||||
recipients: ['user1', 'user2'],
|
||||
},
|
||||
});
|
||||
|
||||
renderWithEverything(
|
||||
<PostOptions
|
||||
post={ownBoRPost}
|
||||
serverUrl={serverUrl}
|
||||
showAddReaction={true}
|
||||
sourceScreen={'DraftScheduledPostOptions'}
|
||||
componentId={'DraftScheduledPostOptions'}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('bor_read_receipts')).toBeVisible();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Read by 2 of 99 recipients')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not show BOR read receipts for other users BoR post', async () => {
|
||||
const otherUserBoRPost = TestHelper.fakePostModel({
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
channelId: TestHelper.basicChannel!.id,
|
||||
userId: TestHelper.generateId(), // Different user
|
||||
message: 'This is someone else\'s BoR post',
|
||||
metadata: {
|
||||
expire_at: Date.now() + 1000000,
|
||||
recipients: ['user1', 'user2'],
|
||||
},
|
||||
});
|
||||
|
||||
renderWithEverything(
|
||||
<PostOptions
|
||||
post={otherUserBoRPost}
|
||||
serverUrl={serverUrl}
|
||||
showAddReaction={true}
|
||||
sourceScreen={'DraftScheduledPostOptions'}
|
||||
componentId={'DraftScheduledPostOptions'}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('bor_read_receipts')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,20 +3,20 @@
|
|||
|
||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||
import {combineLatest, of as of$, Observable} from 'rxjs';
|
||||
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {Permissions, Post, Screens} from '@constants';
|
||||
import {AppBindingLocations} from '@constants/apps';
|
||||
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
|
||||
import AppsManager from '@managers/apps_manager';
|
||||
import {observeChannel, observeIsReadOnlyChannel} from '@queries/servers/channel';
|
||||
import {observeChannel, observeChannelInfo, observeIsReadOnlyChannel} from '@queries/servers/channel';
|
||||
import {observePost, observePostSaved} from '@queries/servers/post';
|
||||
import {observeReactionsForPost} from '@queries/servers/reaction';
|
||||
import {observePermissionForChannel, observePermissionForPost} from '@queries/servers/role';
|
||||
import {observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system';
|
||||
import {observeIsCRTEnabled, observeThreadById} from '@queries/servers/thread';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {isBoRPost, isUnrevealedBoRPost} from '@utils/bor';
|
||||
import {isBoRPost, isOwnBoRPost, isUnrevealedBoRPost} from '@utils/bor';
|
||||
import {toMilliseconds} from '@utils/datetime';
|
||||
import {isMinimumServerVersion} from '@utils/helpers';
|
||||
import {isFromWebhook, isSystemMessage} from '@utils/post';
|
||||
|
|
@ -77,6 +77,7 @@ const withPost = withObservables([], ({post, database}: {post: Post | PostModel}
|
|||
|
||||
const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sourceScreen, database, serverUrl}: EnhancedProps) => {
|
||||
const channel = observeChannel(database, post.channelId);
|
||||
const channelInfo = observeChannelInfo(database, post.channelId);
|
||||
const channelIsArchived = channel.pipe(switchMap((ch: ChannelModel) => of$(ch.deleteAt !== 0)));
|
||||
const currentUser = observeCurrentUser(database);
|
||||
const isLicensed = observeLicense(database).pipe(switchMap((lcs) => of$(lcs?.IsLicensed === 'true')));
|
||||
|
|
@ -85,7 +86,6 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
|||
const postEditTimeLimit = observeConfigIntValue(database, 'PostEditTimeLimit', -1);
|
||||
const bindings = AppsManager.observeBindings(serverUrl, AppBindingLocations.POST_MENU_ITEM);
|
||||
const borPost = isBoRPost(post);
|
||||
const unrevealedBoRPost = isUnrevealedBoRPost(post);
|
||||
|
||||
const canPostPermission = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => observePermissionForChannel(database, c, u, Permissions.CREATE_POST, false)));
|
||||
const hasAddReactionPermission = currentUser.pipe(switchMap((u) => observePermissionForPost(database, post, u, Permissions.ADD_REACTION, true)));
|
||||
|
|
@ -140,9 +140,11 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
|||
)),
|
||||
);
|
||||
|
||||
const canAddReaction = unrevealedBoRPost ? of$(false) : combineLatest([hasAddReactionPermission, channelIsReadOnly, isUnderMaxAllowedReactions, channelIsArchived]).pipe(
|
||||
switchMap(([permission, readOnly, maxAllowed, isArchived]) => {
|
||||
return of$(!isSystemMessage(post) && permission && !readOnly && !isArchived && maxAllowed && showAddReaction);
|
||||
const canAddReaction = combineLatest([hasAddReactionPermission, channelIsReadOnly, isUnderMaxAllowedReactions, channelIsArchived, currentUser]).pipe(
|
||||
switchMap(([permission, readOnly, maxAllowed, isArchived, user]) => {
|
||||
// Can't react on unrevealed BoR posts of other users
|
||||
const preventBoRReaction = isUnrevealedBoRPost(post) && post.userId !== user?.id;
|
||||
return of$(!isSystemMessage(post) && permission && !readOnly && !isArchived && maxAllowed && showAddReaction && !preventBoRReaction);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -155,9 +157,22 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
|||
switchMap((enabled) => (enabled ? observeThreadById(database, post.id) : of$(undefined))),
|
||||
);
|
||||
|
||||
const currentUserId = currentUser.pipe(
|
||||
switchMap((u) => of$(u?.id)),
|
||||
distinctUntilChanged(),
|
||||
const showBoRReadReceipts = combineLatest([currentUser]).pipe(
|
||||
switchMap(([user]) => {
|
||||
return of$(isOwnBoRPost(post, user?.id));
|
||||
}),
|
||||
);
|
||||
|
||||
const borReceiptData = combineLatest([channelInfo]).pipe(
|
||||
switchMap(([info]) => {
|
||||
const revealedCount = post.metadata?.recipients?.length || 0;
|
||||
const totalRecipients = info ? Math.max(0, info.memberCount - 1) : 0;
|
||||
|
||||
return of$({
|
||||
revealedCount,
|
||||
totalRecipients,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -173,7 +188,9 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
|||
thread,
|
||||
bindings,
|
||||
isBoRPost: of$(borPost),
|
||||
currentUserId,
|
||||
showBoRReadReceipts,
|
||||
borReceiptData,
|
||||
currentUser,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
270
app/screens/post_options/options/delete_post_option.test.tsx
Normal file
270
app/screens/post_options/options/delete_post_option.test.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent, screen} from '@testing-library/react-native';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {deletePost, burnPostNow} from '@actions/remote/post';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
import {isBoRPost, isOwnBoRPost} from '@utils/bor';
|
||||
|
||||
import DeletePostOption from './delete_post_option';
|
||||
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('@actions/remote/post');
|
||||
jest.mock('@screens/navigation');
|
||||
jest.mock('@utils/bor');
|
||||
|
||||
const mockDeletePost = deletePost as jest.MockedFunction<typeof deletePost>;
|
||||
const mockBurnPostNow = burnPostNow as jest.MockedFunction<typeof burnPostNow>;
|
||||
const mockDismissBottomSheet = dismissBottomSheet as jest.MockedFunction<typeof dismissBottomSheet>;
|
||||
const mockIsBoRPost = isBoRPost as jest.MockedFunction<typeof isBoRPost>;
|
||||
const mockIsOwnBoRPost = isOwnBoRPost as jest.MockedFunction<typeof isOwnBoRPost>;
|
||||
const mockAlert = Alert.alert as jest.MockedFunction<typeof Alert.alert>;
|
||||
|
||||
describe('DeletePostOption', () => {
|
||||
let database: Database;
|
||||
let mockPost: PostModel;
|
||||
let mockCurrentUser: UserModel;
|
||||
|
||||
beforeAll(async () => {
|
||||
const server = await TestHelper.setupServerDatabase();
|
||||
database = server.database;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockPost = {
|
||||
id: 'post-id-1',
|
||||
userId: 'user-id-1',
|
||||
} as PostModel;
|
||||
|
||||
mockCurrentUser = {
|
||||
id: 'user-id-1',
|
||||
} as UserModel;
|
||||
|
||||
mockDismissBottomSheet.mockResolvedValue();
|
||||
mockDeletePost.mockResolvedValue({post: {post: mockPost}});
|
||||
mockBurnPostNow.mockResolvedValue({post: {post: mockPost}});
|
||||
});
|
||||
|
||||
const getDefaultProps = () => ({
|
||||
bottomSheetId: 'PostOptions' as const,
|
||||
post: mockPost,
|
||||
currentUser: mockCurrentUser,
|
||||
});
|
||||
|
||||
it('should render delete option with correct text and icon', () => {
|
||||
mockIsBoRPost.mockReturnValue(false);
|
||||
|
||||
renderWithEverything(
|
||||
<DeletePostOption {...getDefaultProps()}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Delete')).toBeVisible();
|
||||
expect(screen.getByTestId('post_options.delete_post.option')).toBeVisible();
|
||||
});
|
||||
|
||||
describe('Regular Post Deletion', () => {
|
||||
beforeEach(() => {
|
||||
mockIsBoRPost.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should show confirmation alert when pressed for regular post', () => {
|
||||
renderWithEverything(
|
||||
<DeletePostOption {...getDefaultProps()}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const deleteOption = screen.getByTestId('post_options.delete_post.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
expect(mockAlert).toHaveBeenCalledWith(
|
||||
'Delete Post',
|
||||
'Are you sure you want to delete this post?',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: expect.any(Function),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call deletePost when confirmed for regular post', async () => {
|
||||
renderWithEverything(
|
||||
<DeletePostOption {...getDefaultProps()}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const deleteOption = screen.getByTestId('post_options.delete_post.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
// Get the onPress function from the Delete button and call it
|
||||
const alertCalls = mockAlert.mock.calls;
|
||||
const deleteButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Delete');
|
||||
await deleteButtonConfig?.onPress!();
|
||||
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith('PostOptions');
|
||||
expect(mockDeletePost).toHaveBeenCalledWith(expect.any(String), mockPost);
|
||||
});
|
||||
|
||||
it('should use combinedPost when provided for regular post', async () => {
|
||||
const combinedPost = {id: 'combined-post-id'} as Post;
|
||||
|
||||
renderWithEverything(
|
||||
<DeletePostOption
|
||||
{...getDefaultProps()}
|
||||
combinedPost={combinedPost}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const deleteOption = screen.getByTestId('post_options.delete_post.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
const alertCalls = mockAlert.mock.calls;
|
||||
const deleteButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Delete');
|
||||
await deleteButtonConfig?.onPress!();
|
||||
|
||||
expect(mockDeletePost).toHaveBeenCalledWith(expect.any(String), combinedPost);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BoR Post Deletion', () => {
|
||||
beforeEach(() => {
|
||||
mockIsBoRPost.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should show BoR confirmation alert for sender', () => {
|
||||
mockIsOwnBoRPost.mockReturnValue(true);
|
||||
|
||||
renderWithEverything(
|
||||
<DeletePostOption {...getDefaultProps()}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const deleteOption = screen.getByTestId('post_options.delete_post.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
expect(mockAlert).toHaveBeenCalledWith(
|
||||
'Delete Message Now?',
|
||||
'This message will be permanently deleted for all recipients right away. This action can\'t be undone. Are you sure you want to delete this message?',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: expect.any(Function),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show BoR confirmation alert for receiver', () => {
|
||||
mockIsOwnBoRPost.mockReturnValue(false);
|
||||
|
||||
renderWithEverything(
|
||||
<DeletePostOption {...getDefaultProps()}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const deleteOption = screen.getByTestId('post_options.delete_post.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
expect(mockAlert).toHaveBeenCalledWith(
|
||||
'Delete Message Now?',
|
||||
'This message will be permanently deleted for you right away and can\'t be undone.',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: expect.any(Function),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call burnPostNow when confirmed for BoR post', async () => {
|
||||
mockIsOwnBoRPost.mockReturnValue(true);
|
||||
|
||||
renderWithEverything(
|
||||
<DeletePostOption {...getDefaultProps()}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const deleteOption = screen.getByTestId('post_options.delete_post.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
const alertCalls = mockAlert.mock.calls;
|
||||
const deleteButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Delete');
|
||||
await deleteButtonConfig?.onPress!();
|
||||
|
||||
expect(mockDismissBottomSheet).toHaveBeenCalledWith('PostOptions');
|
||||
expect(mockBurnPostNow).toHaveBeenCalledWith(expect.any(String), mockPost);
|
||||
});
|
||||
|
||||
it('should use combinedPost when provided for BoR post', async () => {
|
||||
const combinedPost = {id: 'combined-post-id'} as Post;
|
||||
mockIsOwnBoRPost.mockReturnValue(true);
|
||||
|
||||
renderWithEverything(
|
||||
<DeletePostOption
|
||||
{...getDefaultProps()}
|
||||
combinedPost={combinedPost}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const deleteOption = screen.getByTestId('post_options.delete_post.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
const alertCalls = mockAlert.mock.calls;
|
||||
const deleteButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Delete');
|
||||
await deleteButtonConfig?.onPress!();
|
||||
|
||||
expect(mockBurnPostNow).toHaveBeenCalledWith(expect.any(String), combinedPost);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call any delete functions when cancel is pressed', () => {
|
||||
mockIsBoRPost.mockReturnValue(false);
|
||||
|
||||
renderWithEverything(
|
||||
<DeletePostOption {...getDefaultProps()}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
const deleteOption = screen.getByTestId('post_options.delete_post.option');
|
||||
fireEvent.press(deleteOption);
|
||||
|
||||
// Simulate pressing Cancel
|
||||
const alertCalls = mockAlert.mock.calls;
|
||||
const cancelButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Cancel');
|
||||
cancelButtonConfig?.onPress?.();
|
||||
|
||||
expect(mockDeletePost).not.toHaveBeenCalled();
|
||||
expect(mockBurnPostNow).not.toHaveBeenCalled();
|
||||
expect(mockDismissBottomSheet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,18 +5,21 @@ import React, {useCallback} from 'react';
|
|||
import {defineMessages, useIntl} from 'react-intl';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {deletePost} from '@actions/remote/post';
|
||||
import {burnPostNow, deletePost} from '@actions/remote/post';
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {isBoRPost, isOwnBoRPost} from '@utils/bor';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
bottomSheetId: AvailableScreens;
|
||||
combinedPost?: Post | PostModel;
|
||||
post: PostModel;
|
||||
currentUser?: UserModel;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -26,17 +29,41 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
const DeletePostOption = ({bottomSheetId, combinedPost, post}: Props) => {
|
||||
const DeletePostOption = ({bottomSheetId, combinedPost, post, currentUser}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
Alert.alert(
|
||||
formatMessage({id: 'mobile.post.delete_title', defaultMessage: 'Delete Post'}),
|
||||
formatMessage({
|
||||
let title: string;
|
||||
let body: string;
|
||||
let deleteAction: (serverUrl: string, postToDelete: PostModel | Post) => Promise<unknown>;
|
||||
|
||||
if (isBoRPost(post)) {
|
||||
title = formatMessage({id: 'mobile.burn_on_read.delete_now.title', defaultMessage: 'Delete Message Now?'});
|
||||
body = formatMessage({
|
||||
id: 'mobile.burn_on_read.delete_now.receiver.body',
|
||||
defaultMessage: 'This message will be permanently deleted for you right away and can\'t be undone.',
|
||||
});
|
||||
deleteAction = burnPostNow;
|
||||
|
||||
if (isOwnBoRPost(post, currentUser?.id)) {
|
||||
body = formatMessage({
|
||||
id: 'mobile.burn_on_read.delete_now.sender.body',
|
||||
defaultMessage: 'This message will be permanently deleted for all recipients right away. This action can\'t be undone. Are you sure you want to delete this message?',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
title = formatMessage({id: 'mobile.post.delete_title', defaultMessage: 'Delete Post'});
|
||||
body = formatMessage({
|
||||
id: 'mobile.post.delete_question',
|
||||
defaultMessage: 'Are you sure you want to delete this post?',
|
||||
}),
|
||||
});
|
||||
deleteAction = deletePost;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
body,
|
||||
[{
|
||||
text: formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}),
|
||||
style: 'cancel',
|
||||
|
|
@ -45,11 +72,12 @@ const DeletePostOption = ({bottomSheetId, combinedPost, post}: Props) => {
|
|||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await dismissBottomSheet(bottomSheetId);
|
||||
deletePost(serverUrl, combinedPost || post);
|
||||
deleteAction(serverUrl, combinedPost || post);
|
||||
},
|
||||
}],
|
||||
);
|
||||
}, [formatMessage, bottomSheetId, serverUrl, combinedPost, post]);
|
||||
|
||||
}, [bottomSheetId, combinedPost, currentUser, formatMessage, post, serverUrl]);
|
||||
|
||||
return (
|
||||
<BaseOption
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {useIsTablet} from '@hooks/device';
|
|||
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||
import BottomSheet from '@screens/bottom_sheet';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import BORReadReceipts, {BOR_READ_RECEIPTS_HEIGHT} from '@screens/post_options/bor_read_receipts';
|
||||
import {isOwnBoRPost, isUnrevealedBoRPost} from '@utils/bor';
|
||||
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||
import {isSystemMessage} from '@utils/post';
|
||||
|
|
@ -27,8 +28,10 @@ import MarkAsUnreadOption from './options/mark_unread_option';
|
|||
import PinChannelOption from './options/pin_channel_option';
|
||||
import ReactionBar from './reaction_bar';
|
||||
|
||||
import type {BurnOnReadRecipientData} from '@typings/components/post_options';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type ThreadModel from '@typings/database/models/servers/thread';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
const POST_OPTIONS_BUTTON = 'close-post-options';
|
||||
|
|
@ -49,14 +52,16 @@ type PostOptionsProps = {
|
|||
bindings: AppBinding[];
|
||||
serverUrl: string;
|
||||
isBoRPost?: boolean;
|
||||
currentUserId?: string;
|
||||
showBoRReadReceipts?: boolean;
|
||||
borReceiptData?: BurnOnReadRecipientData;
|
||||
currentUser?: UserModel;
|
||||
};
|
||||
const PostOptions = ({
|
||||
canAddReaction, canDelete, canEdit,
|
||||
canMarkAsUnread, canPin, canReply,
|
||||
combinedPost, componentId, isSaved,
|
||||
sourceScreen, post, thread, bindings, serverUrl,
|
||||
isBoRPost, currentUserId,
|
||||
isBoRPost, showBoRReadReceipts, borReceiptData, currentUser,
|
||||
}: PostOptionsProps) => {
|
||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||
const isTablet = useIsTablet();
|
||||
|
|
@ -71,15 +76,17 @@ const PostOptions = ({
|
|||
|
||||
const isSystemPost = isSystemMessage(post);
|
||||
|
||||
const canCopyBoRPostPermalink = isBoRPost ? post.userId === currentUserId : true;
|
||||
const canCopyBoRPostPermalink = isBoRPost ? post.userId === currentUser?.id : true;
|
||||
const canCopyPermalink = !isSystemPost && managedConfig?.copyAndPasteProtection !== 'true' && canCopyBoRPostPermalink;
|
||||
const canCopyText = canCopyPermalink && post.message && !isBoRPost;
|
||||
|
||||
const canSavePost = !isSystemPost && (!isUnrevealedBoRPost(post) || isOwnBoRPost(post, currentUserId));
|
||||
const canSavePost = !isSystemPost && (!isUnrevealedBoRPost(post) || isOwnBoRPost(post, currentUser?.id));
|
||||
|
||||
const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread);
|
||||
const shouldShowBindings = bindings.length > 0 && !isSystemPost;
|
||||
|
||||
const shouldShowBORReadReceipts = showBoRReadReceipts && borReceiptData;
|
||||
|
||||
const snapPoints = useMemo(() => {
|
||||
const items: Array<string | number> = [1];
|
||||
const optionsCount = [
|
||||
|
|
@ -89,7 +96,11 @@ const PostOptions = ({
|
|||
return v ? acc + 1 : acc;
|
||||
}, 0) + (shouldShowBindings ? 0.5 : 0);
|
||||
|
||||
items.push(bottomSheetSnapPoint(optionsCount, ITEM_HEIGHT) + (canAddReaction ? REACTION_PICKER_HEIGHT + REACTION_PICKER_MARGIN : 0));
|
||||
items.push(
|
||||
bottomSheetSnapPoint(optionsCount, ITEM_HEIGHT) +
|
||||
(canAddReaction ? REACTION_PICKER_HEIGHT + REACTION_PICKER_MARGIN : 0) +
|
||||
(shouldShowBORReadReceipts ? BOR_READ_RECEIPTS_HEIGHT : 0),
|
||||
);
|
||||
|
||||
if (shouldShowBindings) {
|
||||
items.push('80%');
|
||||
|
|
@ -109,6 +120,12 @@ const PostOptions = ({
|
|||
scrollEnabled={enabled}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{shouldShowBORReadReceipts &&
|
||||
<BORReadReceipts
|
||||
totalReceipts={borReceiptData.totalRecipients}
|
||||
readReceipts={borReceiptData.revealedCount}
|
||||
/>
|
||||
}
|
||||
{canAddReaction &&
|
||||
<ReactionBar
|
||||
bottomSheetId={Screens.POST_OPTIONS}
|
||||
|
|
@ -173,6 +190,7 @@ const PostOptions = ({
|
|||
bottomSheetId={Screens.POST_OPTIONS}
|
||||
combinedPost={combinedPost}
|
||||
post={post}
|
||||
currentUser={currentUser}
|
||||
/>}
|
||||
{shouldShowBindings &&
|
||||
<AppBindingsPostOptions
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getIntlShape} from '@utils/general';
|
||||
|
||||
import {formatTime, getReadableTimestamp, isSameDate, isSameMonth, isSameYear, isToday, isYesterday} from './datetime';
|
||||
|
||||
describe('Datetime', () => {
|
||||
|
|
@ -126,46 +128,48 @@ describe('formatTime', () => {
|
|||
});
|
||||
|
||||
describe('text time format', () => {
|
||||
const intl = getIntlShape();
|
||||
|
||||
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');
|
||||
expect(formatTime(30, true, intl)).toBe('30s');
|
||||
expect(formatTime(5, true, intl)).toBe('5s');
|
||||
expect(formatTime(59, true, intl)).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');
|
||||
expect(formatTime(90, true, intl)).toBe('1m 30s');
|
||||
expect(formatTime(125, true, intl)).toBe('2m 5s');
|
||||
expect(formatTime(3599, true, intl)).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');
|
||||
expect(formatTime(3600, true, intl)).toBe('1h');
|
||||
expect(formatTime(3661, true, intl)).toBe('1h 1m 1s');
|
||||
expect(formatTime(7325, true, intl)).toBe('2h 2m 5s');
|
||||
expect(formatTime(36000, true, intl)).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');
|
||||
expect(formatTime(3660, true, intl)).toBe('1h 1m');
|
||||
expect(formatTime(7200, true, intl)).toBe('2h');
|
||||
});
|
||||
|
||||
it('should format hours and seconds only in text time format', () => {
|
||||
expect(formatTime(3605, true)).toBe('1h 5s');
|
||||
expect(formatTime(3605, true, intl)).toBe('1h 5s');
|
||||
});
|
||||
|
||||
it('should format minutes only in text time format', () => {
|
||||
expect(formatTime(60, true)).toBe('1m');
|
||||
expect(formatTime(120, true)).toBe('2m');
|
||||
expect(formatTime(60, true, intl)).toBe('1m');
|
||||
expect(formatTime(120, true, intl)).toBe('2m');
|
||||
});
|
||||
|
||||
it('should handle zero seconds in text time format', () => {
|
||||
expect(formatTime(0, true)).toBe('0s');
|
||||
expect(formatTime(0, true, intl)).toBe('0s');
|
||||
});
|
||||
|
||||
it('should handle negative values in text time format', () => {
|
||||
expect(formatTime(-30, true)).toBe('0s');
|
||||
expect(formatTime(-3600, true)).toBe('0s');
|
||||
expect(formatTime(-30, true, intl)).toBe('0s');
|
||||
expect(formatTime(-3600, true, intl)).toBe('0s');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {IntlShape} from 'react-intl';
|
||||
|
||||
export function isSameDate(a: Date, b: Date = new Date()): boolean {
|
||||
return a.getDate() === b.getDate() && isSameMonth(a, b) && isSameYear(a, b);
|
||||
}
|
||||
|
|
@ -56,23 +58,23 @@ export function getReadableTimestamp(timestamp: number, timeZone: string, isMili
|
|||
return date.toLocaleString(currentUserLocale, options);
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number, textTime: boolean = false) {
|
||||
export function formatTime(seconds: number, textTime: boolean = false, intl?: IntlShape) {
|
||||
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) {
|
||||
if (textTime && intl) {
|
||||
const parts: string[] = [];
|
||||
if (h > 0) {
|
||||
parts.push(`${h}h`);
|
||||
parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.hours_component', defaultMessage: '{hours}h'}, {hours: h}));
|
||||
}
|
||||
if (m > 0) {
|
||||
parts.push(`${m}m`);
|
||||
parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.minutes_component', defaultMessage: '{minutes}m'}, {minutes: m}));
|
||||
}
|
||||
if (s > 0) {
|
||||
parts.push(`${s}s`);
|
||||
parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.seconds_component', defaultMessage: '{seconds}s'}, {seconds: s}));
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' ') : '0s';
|
||||
return parts.length > 0 ? parts.join(' ') : intl.formatMessage({id: 'mobile.format_time.text_time.seconds_component', defaultMessage: '{seconds}s'}, {seconds: 0});
|
||||
}
|
||||
|
||||
const hh = h > 0 ? `${h}:` : '';
|
||||
|
|
|
|||
|
|
@ -540,7 +540,12 @@
|
|||
"mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.",
|
||||
"mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos",
|
||||
"mobile.announcement_banner.title": "Announcement",
|
||||
"mobile.burn_on_read.delete_now.receiver.body": "This message will be permanently deleted for you right away and can't be undone.",
|
||||
"mobile.burn_on_read.delete_now.sender.body": "This message will be permanently deleted for all recipients right away. This action can't be undone. Are you sure you want to delete this message?",
|
||||
"mobile.burn_on_read.delete_now.title": "Delete Message Now?",
|
||||
"mobile.burn_on_read.placeholder": "View message",
|
||||
"mobile.burn_on_read.read_receipt.text": "Read by {readReceipts} of {totalReceipts} recipients",
|
||||
"mobile.burn_on_read.read_receipt.title": "Burn-on-read message",
|
||||
"mobile.calls_audio_device": "Select audio device",
|
||||
"mobile.calls_bluetooth": "Bluetooth",
|
||||
"mobile.calls_call_ended": "Call ended",
|
||||
|
|
@ -717,6 +722,9 @@
|
|||
"mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.",
|
||||
"mobile.files_paste.error_dismiss": "Dismiss",
|
||||
"mobile.files_paste.error_title": "Paste failed",
|
||||
"mobile.format_time.text_time.hours_component": "{hours}h",
|
||||
"mobile.format_time.text_time.minutes_component": "{minutes}m",
|
||||
"mobile.format_time.text_time.seconds_component": "{seconds}s",
|
||||
"mobile.gallery.title": "{index} of {total}",
|
||||
"mobile.integration_selector.loading_channels": "Loading channels...",
|
||||
"mobile.integration_selector.loading_options": "Loading options...",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class TestHelperSingleton {
|
|||
basicCategory: Category | null;
|
||||
basicCategoryChannel: CategoryChannel | null;
|
||||
basicChannel: Channel | null;
|
||||
basicChannelInfo: ChannelInfo | null;
|
||||
basicChannelMember: ChannelMembership | null;
|
||||
basicMyChannel: ChannelMembership | null;
|
||||
basicMyChannelSettings: ChannelMembership | null;
|
||||
|
|
@ -75,6 +76,7 @@ class TestHelperSingleton {
|
|||
this.basicCategory = null;
|
||||
this.basicCategoryChannel = null;
|
||||
this.basicChannel = null;
|
||||
this.basicChannelInfo = null;
|
||||
this.basicChannelMember = null;
|
||||
this.basicMyChannel = null;
|
||||
this.basicMyChannelSettings = null;
|
||||
|
|
@ -118,6 +120,10 @@ class TestHelperSingleton {
|
|||
channels: [this.basicChannel!],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
await operator.handleChannelInfo({
|
||||
channelInfos: [this.basicChannelInfo!],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
await operator.handleMyChannel({
|
||||
prepareRecordsOnly: false,
|
||||
channels: [this.basicChannel!],
|
||||
|
|
@ -1461,6 +1467,7 @@ class TestHelperSingleton {
|
|||
this.basicTeamMember = this.fakeTeamMember(this.basicUser.id, this.basicTeam.id);
|
||||
this.basicCategory = this.fakeCategoryWithId(this.basicTeam.id);
|
||||
this.basicChannel = this.fakeChannelWithId(this.basicTeam.id);
|
||||
this.basicChannelInfo = this.fakeChannelInfo({id: this.basicChannel.id, member_count: 100});
|
||||
this.basicCategoryChannel = this.fakeCategoryChannelWithId(this.basicTeam.id, this.basicCategory.id, this.basicChannel.id);
|
||||
this.basicChannelMember = this.fakeChannelMember({user_id: this.basicUser.id, channel_id: this.basicChannel.id});
|
||||
this.basicMyChannel = this.fakeMyChannel({user_id: this.basicUser.id, channel_id: this.basicChannel.id});
|
||||
|
|
|
|||
3
types/api/config.d.ts
vendored
3
types/api/config.d.ts
vendored
|
|
@ -22,6 +22,8 @@ interface ClientConfig {
|
|||
BuildHash: string;
|
||||
BuildHashEnterprise: string;
|
||||
BuildNumber: string;
|
||||
BurnOnReadMaximumTimeToLiveSeconds: string;
|
||||
BurnOnReadDurationSeconds: string;
|
||||
CloseUnusedDirectMessages: string;
|
||||
CollapsedThreads: string;
|
||||
CustomBrandText: string;
|
||||
|
|
@ -46,6 +48,7 @@ interface ClientConfig {
|
|||
EmailNotificationContentsType: string;
|
||||
EnableBanner: string;
|
||||
EnableBotAccountCreation: string;
|
||||
EnableBurnOnRead: string;
|
||||
EnableChannelViewedMessages: string;
|
||||
EnableClientMetrics?: string;
|
||||
EnableCluster: string;
|
||||
|
|
|
|||
8
types/api/posts.d.ts
vendored
8
types/api/posts.d.ts
vendored
|
|
@ -80,6 +80,8 @@ type PostMetadata = {
|
|||
reactions?: Reaction[];
|
||||
priority?: PostPriority;
|
||||
expire_at?: number;
|
||||
borConfig?: PostBoRConfig;
|
||||
recipients?: string[];
|
||||
};
|
||||
|
||||
type Post = {
|
||||
|
|
@ -178,3 +180,9 @@ type FetchPaginatedThreadOptions = {
|
|||
fromCreateAt?: number;
|
||||
fromPost?: string;
|
||||
}
|
||||
|
||||
type PostBoRConfig = {
|
||||
enabled: boolean;
|
||||
borDurationSeconds: number;
|
||||
borMaximumTimeToLiveSeconds: number;
|
||||
}
|
||||
|
|
|
|||
7
types/components/post_options.ts
Normal file
7
types/components/post_options.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export type BurnOnReadRecipientData = {
|
||||
revealedCount: number;
|
||||
totalRecipients: number;
|
||||
}
|
||||
2
types/database/raw_values.d.ts
vendored
2
types/database/raw_values.d.ts
vendored
|
|
@ -24,7 +24,7 @@ type Draft = {
|
|||
root_id: string;
|
||||
metadata?: PostMetadata;
|
||||
update_at: number;
|
||||
type?: string;
|
||||
type?: PostType;
|
||||
};
|
||||
|
||||
type MyTeam = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue