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:
Harshil Sharma 2026-02-09 17:00:54 +05:30 committed by GitHub
parent fe621047ce
commit 04de7b9745
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2016 additions and 100 deletions

View file

@ -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('');
});
});

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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();
});
});
});

View file

@ -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};
}
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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;

View file

@ -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,

View file

@ -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

View file

@ -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}
/>
);
}

View file

@ -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);
});
});
});

View file

@ -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}

View file

@ -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>
);
}

View file

@ -100,6 +100,7 @@ function PostDraft({
updateValue={setValue}
value={value}
setIsFocused={setIsFocused}
location={location}
/>
);

View file

@ -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>
);
}

View file

@ -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,
});
});
});

View file

@ -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));

View file

@ -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,
};
});

View file

@ -22,7 +22,7 @@ type Props = {
updatePostPriority: (postPriority: PostPriority) => void;
}
const style = StyleSheet.create({
export const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',

View file

@ -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();
});
});
});

View file

@ -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>
);
}

View file

@ -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,
};
});

View file

@ -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}
/>
);
}

View file

@ -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}

View file

@ -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();

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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 ***', () => {

View file

@ -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;
}

View file

@ -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({

View file

@ -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);
});
});

View file

@ -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);

View file

@ -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);
});
});
});

View file

@ -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');
};

View 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();
});
});

View 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>
);
}

View file

@ -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();
});
});
});

View file

@ -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,
};
});

View 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();
});
});

View file

@ -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

View file

@ -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

View file

@ -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');
});
});
});

View file

@ -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}:` : '';

View file

@ -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...",

View file

@ -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});

View file

@ -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;

View file

@ -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;
}

View 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;
}

View file

@ -24,7 +24,7 @@ type Draft = {
root_id: string;
metadata?: PostMetadata;
update_at: number;
type?: string;
type?: PostType;
};
type MyTeam = {