Burn On Read Posts (#9307)
* Added scaffolding for unrevealed BoR post * Displayed reveal UI * Displayed expiry timer * WIP * Displayed own post indicator and blur text * restored button * Ungrouped BoR posts * WIP * WIP * DIsplayed error message on revealing * Added last run check on mobile app cleanup job * Cleanup * lint fix * i18n-fix * Added tests * test: add test suite for revealBoRPost function * test: add unrevealed burn on read post test file * feat: add tests for UnrevealedBurnOnReadPost component * test: update UnrevealedBurnOnReadPost test with PostModel type * test: replace toBeTruthy with toBeVisible for component visibility assertions * test: add initial test file for expiry timer component * test: add comprehensive tests for ExpiryCountdown component * refactor: clean up test formatting and remove redundant test case * fix: adjust test timing for ExpiryCountdown onExpiry callback * test: fix timer test by advancing timers in smaller increments * Added tests * Updated tests * fixed accidental change * restored package.resolved * WIP review fixes * Review fixes * Review fixes * Fixed tests * restored package.resolved * WIP * test: add test for skipping BoR post cleanup within 15 minutes * test: add comprehensive test cases for expiredBoRPostCleanup * WIP * WIP * test: add bor.test.ts placeholder file * test: add comprehensive tests for BoR utility functions * test: add comprehensive tests for formatTime function * WIP * test: add comprehensive tests for getLastBoRPostCleanupRun function * Added tests * removed a commented code * post list optimization * test: add test case for updating unrevealed burn-on-read post * fix: handle error logging in expiredBoRPostCleanup test * test: add test case for updateLastBoRCleanupRun error handling * review fixes * lint fixes * Added WS event handling (#9320) * Added WS event handling * test: add burn on read websocket action test * test: add tests for handleBoRPostRevealedEvent in burn_on_read * Added tests * test: add comprehensive error handling test cases for burn on read * Added tests and error handling * BoR post - restricted actions (#9315) * Restricted post actions for BoR post type * Prevent opening thread fr nBoR post * WIP * fix: remove redundant observable wrapping in post options * fixed a change * removed broken * restored package.resolved * Added tests * Awaiting for last run to be set * Added tests for validating expiry timer behaviour (#9338) * Added tests for validating expiry timer behaviour * No need of use event setup * Bor ux fixes (#9336) * WIP * UI and delete fixes * lint fixes * fixed tests * Improved mocking * Improved test
This commit is contained in:
parent
ebb7a2241f
commit
2fb2e9d899
46 changed files with 1817 additions and 37 deletions
|
|
@ -3,10 +3,13 @@
|
|||
|
||||
import Database from '@nozbe/watermelondb/Database';
|
||||
|
||||
import {getPosts} from '@actions/local/post';
|
||||
import {ActionType} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import TestHelper from '@test/test_helper';
|
||||
import {logError} from '@utils/log';
|
||||
|
||||
import {
|
||||
storeConfig,
|
||||
|
|
@ -17,6 +20,7 @@ import {
|
|||
setLastServerVersionCheck,
|
||||
setGlobalThreadsTab,
|
||||
dismissAnnouncement,
|
||||
expiredBoRPostCleanup,
|
||||
} from './systems';
|
||||
|
||||
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
|
||||
|
|
@ -251,3 +255,269 @@ describe('dismissAnnouncement', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('expiredBoRPostCleanup', () => {
|
||||
it('should delete expired BoR posts', async () => {
|
||||
const database = operator.database;
|
||||
jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve());
|
||||
|
||||
const channel: Channel = TestHelper.fakeChannel({
|
||||
id: 'channelid1',
|
||||
team_id: 'teamid1',
|
||||
});
|
||||
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const borPostExpiredForAll = TestHelper.fakePost({
|
||||
id: 'postid1',
|
||||
channel_id: channel.id,
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now - 10000},
|
||||
});
|
||||
|
||||
const borPostExpiredForMe = TestHelper.fakePost({
|
||||
id: 'postid2',
|
||||
channel_id: channel.id,
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now + 100000},
|
||||
metadata: {expire_at: now - 10000},
|
||||
});
|
||||
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [borPostExpiredForAll.id, borPostExpiredForMe.id],
|
||||
posts: [borPostExpiredForAll, borPostExpiredForMe],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// verify channel posts
|
||||
const fetchedPosts = await getPosts(serverUrl, [borPostExpiredForAll.id, borPostExpiredForMe.id]);
|
||||
expect(fetchedPosts.length).toBe(2);
|
||||
|
||||
await expiredBoRPostCleanup(serverUrl);
|
||||
|
||||
expect(database.adapter.unsafeExecute).toHaveBeenCalledWith({
|
||||
sqls: [
|
||||
[`DELETE FROM Post where id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []],
|
||||
[`DELETE FROM Reaction where post_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []],
|
||||
[`DELETE FROM File where post_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []],
|
||||
[`DELETE FROM Draft where root_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []],
|
||||
[`DELETE FROM PostsInThread where root_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []],
|
||||
[`DELETE FROM Thread where id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []],
|
||||
[`DELETE FROM ThreadParticipant where thread_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []],
|
||||
[`DELETE FROM ThreadsInTeam where thread_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not run cleanup when called again within 15 minutes', async () => {
|
||||
const database = operator.database;
|
||||
const unsafeExecuteSpy = jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve());
|
||||
|
||||
// Set up a recent last run time (within 15 minutes)
|
||||
const recentRunTime = Date.now() - (10 * 60 * 1000); // 10 minutes ago
|
||||
await operator.handleSystem({
|
||||
systems: [{
|
||||
id: SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN,
|
||||
value: recentRunTime,
|
||||
}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const channel: Channel = TestHelper.fakeChannel({
|
||||
id: 'channelid1',
|
||||
team_id: 'teamid1',
|
||||
});
|
||||
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||
|
||||
const now = Date.now();
|
||||
const borPostExpired = TestHelper.fakePost({
|
||||
id: 'postid1',
|
||||
channel_id: channel.id,
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now - 10000},
|
||||
});
|
||||
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [borPostExpired.id],
|
||||
posts: [borPostExpired],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// Call cleanup - should not run because last run was within 15 minutes
|
||||
await expiredBoRPostCleanup(serverUrl);
|
||||
|
||||
// Verify that unsafeExecute was not called (no cleanup performed)
|
||||
expect(unsafeExecuteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle no server database gracefully', async () => {
|
||||
// Try to run cleanup on a non-existent server
|
||||
await expect(expiredBoRPostCleanup('nonexistent.server.com')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle no BoR posts gracefully', async () => {
|
||||
const database = operator.database;
|
||||
const unsafeExecuteSpy = jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve());
|
||||
|
||||
const channel: Channel = TestHelper.fakeChannel({
|
||||
id: 'channelid1',
|
||||
team_id: 'teamid1',
|
||||
});
|
||||
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||
await expiredBoRPostCleanup(serverUrl);
|
||||
|
||||
// Verify that unsafeExecute was not called (no BoR posts to clean)
|
||||
expect(unsafeExecuteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle BoR posts that are not expired', async () => {
|
||||
const database = operator.database;
|
||||
const unsafeExecuteSpy = jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve());
|
||||
|
||||
const channel: Channel = TestHelper.fakeChannel({
|
||||
id: 'channelid1',
|
||||
team_id: 'teamid1',
|
||||
});
|
||||
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Create BoR posts that are not expired
|
||||
const borPostNotExpired = TestHelper.fakePost({
|
||||
id: 'postid1',
|
||||
channel_id: channel.id,
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now + 100000}, // Future expiry
|
||||
});
|
||||
|
||||
const borPostNotExpiredForMe = TestHelper.fakePost({
|
||||
id: 'postid2',
|
||||
channel_id: channel.id,
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now + 10000},
|
||||
metadata: {expire_at: now + 100000},
|
||||
});
|
||||
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [borPostNotExpired.id, borPostNotExpiredForMe.id],
|
||||
posts: [borPostNotExpired, borPostNotExpiredForMe],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await expiredBoRPostCleanup(serverUrl);
|
||||
|
||||
// Verify that unsafeExecute was not called (no expired BoR posts)
|
||||
expect(unsafeExecuteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle mixed expired and non-expired BoR posts', async () => {
|
||||
const database = operator.database;
|
||||
jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve());
|
||||
|
||||
const channel: Channel = TestHelper.fakeChannel({
|
||||
id: 'channelid1',
|
||||
team_id: 'teamid1',
|
||||
});
|
||||
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const borPostExpired = TestHelper.fakePost({
|
||||
id: 'postid1',
|
||||
channel_id: channel.id,
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now - 10000}, // Expired
|
||||
});
|
||||
|
||||
const borPostNotExpired = TestHelper.fakePost({
|
||||
id: 'postid2',
|
||||
channel_id: channel.id,
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now + 100000}, // Not expired
|
||||
});
|
||||
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [borPostExpired.id, borPostNotExpired.id],
|
||||
posts: [borPostExpired, borPostNotExpired],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await expiredBoRPostCleanup(serverUrl);
|
||||
|
||||
// Should only delete the expired post
|
||||
expect(database.adapter.unsafeExecute).toHaveBeenCalledWith({
|
||||
sqls: [
|
||||
[`DELETE FROM Post where id IN ('${borPostExpired.id}')`, []],
|
||||
[`DELETE FROM Reaction where post_id IN ('${borPostExpired.id}')`, []],
|
||||
[`DELETE FROM File where post_id IN ('${borPostExpired.id}')`, []],
|
||||
[`DELETE FROM Draft where root_id IN ('${borPostExpired.id}')`, []],
|
||||
[`DELETE FROM PostsInThread where root_id IN ('${borPostExpired.id}')`, []],
|
||||
[`DELETE FROM Thread where id IN ('${borPostExpired.id}')`, []],
|
||||
[`DELETE FROM ThreadParticipant where thread_id IN ('${borPostExpired.id}')`, []],
|
||||
[`DELETE FROM ThreadsInTeam where thread_id IN ('${borPostExpired.id}')`, []],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const database = operator.database;
|
||||
|
||||
// Mock database query to throw an error
|
||||
jest.spyOn(database, 'get').mockImplementation(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
// Should not throw an error, just log it
|
||||
await expect(expiredBoRPostCleanup(serverUrl)).resolves.not.toThrow();
|
||||
expect(logError).toHaveBeenCalledWith('An error occurred while performing BoR post cleanup', expect.any(Error));
|
||||
});
|
||||
|
||||
it('should handle updateLastBoRCleanupRun error gracefully', async () => {
|
||||
const database = operator.database;
|
||||
jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve());
|
||||
|
||||
// Mock handleSystem to throw an error when updating last cleanup run
|
||||
const handleSystemSpy = jest.spyOn(operator, 'handleSystem').mockImplementation(() => {
|
||||
throw new Error('System update error');
|
||||
});
|
||||
|
||||
const channel: Channel = TestHelper.fakeChannel({
|
||||
id: 'channelid1',
|
||||
team_id: 'teamid1',
|
||||
});
|
||||
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
|
||||
|
||||
// Restore handleSystem for channel creation, then mock it again for the cleanup run update
|
||||
handleSystemSpy.mockRestore();
|
||||
jest.spyOn(operator, 'handleSystem').mockImplementation((args) => {
|
||||
if (args.systems?.[0]?.id === SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN) {
|
||||
throw new Error('System update error');
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const borPostExpired = TestHelper.fakePost({
|
||||
id: 'postid1',
|
||||
channel_id: channel.id,
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now - 10000},
|
||||
});
|
||||
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [borPostExpired.id],
|
||||
posts: [borPostExpired],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// Should not throw an error, just log it
|
||||
await expect(expiredBoRPostCleanup(serverUrl)).resolves.not.toThrow();
|
||||
expect(logError).toHaveBeenCalledWith('Failed updateLastBoRCleanupRun', expect.any(Error));
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,11 +7,22 @@ import {DeviceEventEmitter} from 'react-native';
|
|||
|
||||
import {Events} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {PostTypes, BOR_POST_CLEANUP_MIN_RUN_INTERVAL} from '@constants/post';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getServerCredentials} from '@init/credentials';
|
||||
import {queryAllChannelsForTeam} from '@queries/servers/channel';
|
||||
import {getConfig, getLicense, getGlobalDataRetentionPolicy, getGranularDataRetentionPolicies, getLastGlobalDataRetentionRun, getIsDataRetentionEnabled} from '@queries/servers/system';
|
||||
import {queryPostsByType} from '@queries/servers/post';
|
||||
import {
|
||||
getConfig,
|
||||
getLicense,
|
||||
getGlobalDataRetentionPolicy,
|
||||
getGranularDataRetentionPolicies,
|
||||
getLastGlobalDataRetentionRun,
|
||||
getIsDataRetentionEnabled,
|
||||
getLastBoRPostCleanupRun,
|
||||
} from '@queries/servers/system';
|
||||
import PostModel from '@typings/database/models/servers/post';
|
||||
import {isExpiredBoRPost} from '@utils/bor';
|
||||
import {logError} from '@utils/log';
|
||||
|
||||
import {deletePosts} from './post';
|
||||
|
|
@ -239,7 +250,7 @@ async function dataRetentionWithoutPolicyCleanup(serverUrl: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function dataRetentionCleanPosts(serverUrl: string, postIds: string[]) {
|
||||
export async function dataRetentionCleanPosts(serverUrl: string, postIds: string[]) {
|
||||
if (postIds.length) {
|
||||
const batchSize = 1000;
|
||||
const deletePromises = [];
|
||||
|
|
@ -314,3 +325,55 @@ export async function dismissAnnouncement(serverUrl: string, announcementText: s
|
|||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function expiredBoRPostCleanup(serverUrl: string) {
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const lastRunAt = await getLastBoRPostCleanupRun(database);
|
||||
|
||||
const shouldRunNow = (Date.now() - lastRunAt) > BOR_POST_CLEANUP_MIN_RUN_INTERVAL;
|
||||
|
||||
if (!shouldRunNow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error} = await removeExpiredBoRPosts(serverUrl);
|
||||
if (!error) {
|
||||
await updateLastBoRCleanupRun(serverUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('An error occurred running the Burn on Read cleanup task', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeExpiredBoRPosts(serverUrl: string) {
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const allBoRPosts = await queryPostsByType(database, PostTypes.BURN_ON_READ).fetch();
|
||||
const expiredBoRPostIDs = allBoRPosts.
|
||||
filter((post) => isExpiredBoRPost(post)).
|
||||
map((post) => post.id);
|
||||
|
||||
await dataRetentionCleanPosts(serverUrl, expiredBoRPostIDs);
|
||||
return {error: undefined};
|
||||
} catch (error) {
|
||||
logError('An error occurred while performing BoR post cleanup', error);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLastBoRCleanupRun(serverUrl: string) {
|
||||
try {
|
||||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
const systems: IdValue[] = [{
|
||||
id: SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN,
|
||||
value: Date.now(),
|
||||
}];
|
||||
|
||||
return operator.handleSystem({systems, prepareRecordsOnly: false});
|
||||
} catch (error) {
|
||||
logError('Failed updateLastBoRCleanupRun', error);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
editPost,
|
||||
acknowledgePost,
|
||||
unacknowledgePost,
|
||||
revealBoRPost,
|
||||
fetchPostsForChannel,
|
||||
fetchPostsForUnreadChannels,
|
||||
fetchPosts,
|
||||
|
|
@ -100,6 +101,17 @@ const mockClient = {
|
|||
patchPost: jest.fn((message: string, postId: string) => ({...post1, id: postId, message})),
|
||||
acknowledgePost: jest.fn(() => ({acknowledged_at: acknowledgedTime})),
|
||||
unacknowledgePost: jest.fn(),
|
||||
revealBoRPost: jest.fn((_postId: string) => ({
|
||||
...post1,
|
||||
id: _postId,
|
||||
message: 'Revealed message content',
|
||||
metadata: {
|
||||
files: [{id: 'file1', name: 'revealed-file.pdf'}],
|
||||
},
|
||||
props: {
|
||||
revealed: true,
|
||||
},
|
||||
})),
|
||||
getPosts: genericGetPostsMock,
|
||||
getPostsBefore: genericGetPostsMock,
|
||||
getPostsSince: jest.fn((_channelId: string, since: number) => ({posts: {[post1.id]: {...post1, channel_id: _channelId, create_at: since + 1}, [post2.id]: {...post2, channel_id: _channelId, create_at: since + 2}}, order: [post1.id, post2.id]})),
|
||||
|
|
@ -1112,3 +1124,54 @@ describe('get posts', () => {
|
|||
expect(result.posts?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revealBoRPost', () => {
|
||||
it('revealBoRPost - handle database not found', async () => {
|
||||
const result = await revealBoRPost('foo', '');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('revealBoRPost - handle client error', async () => {
|
||||
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
|
||||
|
||||
const result = await revealBoRPost(serverUrl, post1.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('revealBoRPost - handle error', async () => {
|
||||
mockClient.revealBoRPost.mockImplementationOnce(jest.fn(throwFunc));
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [post1.id],
|
||||
posts: [{...post1, props: {failed: false}}],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await revealBoRPost(serverUrl, post1.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('revealBoRPost - base case', async () => {
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [post1.id],
|
||||
posts: [post1],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await revealBoRPost(serverUrl, post1.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.post).toBeDefined();
|
||||
});
|
||||
|
||||
it('revealBoRPost - post not found', async () => {
|
||||
const result = await revealBoRPost(serverUrl, 'nonexistent-post-id');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.post).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -958,6 +958,32 @@ export const editPost = async (serverUrl: string, postId: string, postMessage: s
|
|||
}
|
||||
};
|
||||
|
||||
export const revealBoRPost = async (serverUrl: string, postId: string) => {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
const post = await getPostById(database, postId);
|
||||
if (!post) {
|
||||
return {post: undefined};
|
||||
}
|
||||
|
||||
const revealedPost = await client.revealBoRPost(postId);
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [revealedPost.id],
|
||||
posts: [revealedPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
return {post};
|
||||
} catch (error) {
|
||||
logDebug('error on revealBoRPost', getFullErrorMessage(error));
|
||||
forceLogoutIfNecessary(serverUrl, error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchSavedPosts(serverUrl: string, teamId?: string, channelId?: string, page?: number, perPage?: number) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
|
|
|
|||
108
app/actions/websocket/burn_on_read.test.ts
Normal file
108
app/actions/websocket/burn_on_read.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import {handleBoRPostRevealedEvent} from './burn_on_read';
|
||||
|
||||
jest.mock('@actions/websocket/posts');
|
||||
jest.mock('@queries/servers/post');
|
||||
|
||||
const serverUrl = 'burnOnRead.test.com';
|
||||
|
||||
describe('WebSocket Burn on Read Actions', () => {
|
||||
const post = TestHelper.fakePost({id: 'post1', channel_id: 'channel1', user_id: 'user1', create_at: 12345, message: 'hello'});
|
||||
|
||||
const mockedGetPostById = jest.mocked(getPostById);
|
||||
const mockedHandleNewPostEvent = jest.mocked(handleNewPostEvent);
|
||||
const mockedHandlePostEdited = jest.mocked(handlePostEdited);
|
||||
|
||||
beforeEach(async () => {
|
||||
await DatabaseManager.init([serverUrl]);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await DatabaseManager.deleteServerDatabase(serverUrl);
|
||||
});
|
||||
|
||||
describe('handleBoRPostRevealedEvent', () => {
|
||||
const msg = {
|
||||
data: {
|
||||
post: JSON.stringify(post),
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
it('should handle new post when post does not exist locally', async () => {
|
||||
mockedGetPostById.mockResolvedValue(undefined);
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedHandleNewPostEvent).toHaveBeenCalledWith(serverUrl, msg);
|
||||
expect(mockedHandlePostEdited).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle post edited when post exists locally', async () => {
|
||||
const existingPost = TestHelper.fakePostModel({id: 'post1'});
|
||||
mockedGetPostById.mockResolvedValue(existingPost);
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
|
||||
expect(mockedHandlePostEdited).toHaveBeenCalledWith(serverUrl, msg);
|
||||
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle malformed post data gracefully', async () => {
|
||||
const malformedMsg = {
|
||||
data: {
|
||||
post: 'invalid json',
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, malformedMsg);
|
||||
|
||||
expect(mockedGetPostById).not.toHaveBeenCalled();
|
||||
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
|
||||
expect(mockedHandlePostEdited).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing server database gracefully', async () => {
|
||||
await handleBoRPostRevealedEvent('invalid-server-url', msg);
|
||||
|
||||
expect(mockedGetPostById).not.toHaveBeenCalled();
|
||||
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
|
||||
expect(mockedHandlePostEdited).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing operator gracefully', async () => {
|
||||
// Mock a server database without an operator
|
||||
DatabaseManager.serverDatabases[serverUrl] = {} as any;
|
||||
|
||||
await handleBoRPostRevealedEvent(serverUrl, msg);
|
||||
|
||||
expect(mockedGetPostById).not.toHaveBeenCalled();
|
||||
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
|
||||
expect(mockedHandlePostEdited).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle JSON parse error gracefully', async () => {
|
||||
const invalidJsonMsg = {
|
||||
data: {
|
||||
post: '{"invalid": json}',
|
||||
},
|
||||
} as WebSocketMessage;
|
||||
|
||||
const result = await handleBoRPostRevealedEvent(serverUrl, invalidJsonMsg);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedGetPostById).not.toHaveBeenCalled();
|
||||
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
|
||||
expect(mockedHandlePostEdited).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
36
app/actions/websocket/burn_on_read.ts
Normal file
36
app/actions/websocket/burn_on_read.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import {logError} from '@utils/log';
|
||||
|
||||
export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSocketMessage) {
|
||||
try {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {database} = operator;
|
||||
let post: Post;
|
||||
try {
|
||||
post = JSON.parse(msg.data.post);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingPost = await getPostById(database, post.id);
|
||||
if (existingPost) {
|
||||
await handlePostEdited(serverUrl, msg);
|
||||
} else {
|
||||
await handleNewPostEvent(serverUrl, msg);
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
logError('handleBoRPostRevealedEvent could not handle websocket event for revealed burn-on-read post', error);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import {handleAgentPostUpdate} from '@agents/actions/websocket';
|
||||
|
||||
import * as bookmark from '@actions/local/channel_bookmark';
|
||||
import {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';
|
||||
|
|
@ -308,6 +309,11 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
|
|||
case WebsocketEvents.AGENTS_POST_UPDATE:
|
||||
handleAgentPostUpdate(msg);
|
||||
break;
|
||||
|
||||
// Burn on Read Events
|
||||
case WebsocketEvents.BOR_POST_REVEALED:
|
||||
handleBoRPostRevealedEvent(serverUrl, msg);
|
||||
break;
|
||||
}
|
||||
handlePlaybookEvents(serverUrl, msg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {markChannelAsViewed} from '@actions/local/channel';
|
||||
import {dataRetentionCleanup} from '@actions/local/systems';
|
||||
import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems';
|
||||
import {markChannelAsRead} from '@actions/remote/channel';
|
||||
import {entry, handleEntryAfterLoadNavigation} from '@actions/remote/entry/common';
|
||||
import {deferredAppEntryActions} from '@actions/remote/entry/deferred';
|
||||
|
|
@ -160,6 +160,7 @@ describe('WebSocket Index Actions', () => {
|
|||
expect(deferredAppEntryActions).toHaveBeenCalled();
|
||||
expect(openAllUnreadChannels).toHaveBeenCalled();
|
||||
expect(dataRetentionCleanup).toHaveBeenCalled();
|
||||
expect(expiredBoRPostCleanup).toHaveBeenCalled();
|
||||
expect(AppsManager.refreshAppBindings).toHaveBeenCalled();
|
||||
expect(handlePlaybookReconnect).toHaveBeenCalledWith(serverUrl);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {markChannelAsViewed} from '@actions/local/channel';
|
||||
import {dataRetentionCleanup} from '@actions/local/systems';
|
||||
import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems';
|
||||
import {markChannelAsRead} from '@actions/remote/channel';
|
||||
import {
|
||||
entry,
|
||||
|
|
@ -104,6 +104,8 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel
|
|||
|
||||
dataRetentionCleanup(serverUrl);
|
||||
|
||||
expiredBoRPostCleanup(serverUrl);
|
||||
|
||||
AppsManager.refreshAppBindings(serverUrl, groupLabel);
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -308,4 +308,13 @@ describe('ClientPosts', () => {
|
|||
{method: 'post'},
|
||||
);
|
||||
});
|
||||
|
||||
test('revealBoRPost', async () => {
|
||||
await client.revealBoRPost('post_id');
|
||||
|
||||
expect(client.doFetch).toHaveBeenCalledWith(
|
||||
`${client.getPostRoute('post_id')}/reveal`,
|
||||
{method: 'get'},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface ClientPostsMix {
|
|||
acknowledgePost: (postId: string, userId: string) => Promise<PostAcknowledgement>;
|
||||
unacknowledgePost: (postId: string, userId: string) => Promise<any>;
|
||||
sendTestNotification: () => Promise<{status: 'OK'}>;
|
||||
revealBoRPost: (postId: string) => Promise<Post>;
|
||||
}
|
||||
|
||||
const ClientPosts = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
|
||||
|
|
@ -228,6 +229,13 @@ const ClientPosts = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
|
|||
{method: 'post'},
|
||||
);
|
||||
};
|
||||
|
||||
revealBoRPost = async (postId: string) => {
|
||||
return this.doFetch(
|
||||
`${this.getPostRoute(postId)}/reveal`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default ClientPosts;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const BOR_POST_EXPIRED_FOR_USER_ERROR_CODE = 'app.reveal_post.read_receipt_expired.error';
|
||||
|
||||
export const BOR_GLOBALLY_EXPIRED_POST_ERROR_CODE = 'app.reveal_post.post_expired.app_error';
|
||||
|
||||
export const BOR_POST_NOT_FOUND_ERROR_CODE = 'app.post.get.app_error';
|
||||
|
||||
export const BOR_ERROR_CODES = [
|
||||
BOR_POST_EXPIRED_FOR_USER_ERROR_CODE,
|
||||
BOR_GLOBALLY_EXPIRED_POST_ERROR_CODE,
|
||||
BOR_POST_NOT_FOUND_ERROR_CODE,
|
||||
];
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fireEvent, screen} from '@testing-library/react-native';
|
||||
import React, {act} from 'react';
|
||||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import {deletePost, revealBoRPost} from '@actions/remote/post';
|
||||
import {BOR_ERROR_CODES} from '@components/post_list/post/burn_on_read/unrevealed/constants';
|
||||
import {PostModel} from '@database/models/server';
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
import {showBoRPostErrorSnackbar} from '@utils/snack_bar';
|
||||
|
||||
import UnrevealedBurnOnReadPost from '.';
|
||||
|
||||
jest.mock('@actions/remote/post', () => ({
|
||||
revealBoRPost: jest.fn(),
|
||||
deletePost: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@actions/local/post', () => ({
|
||||
removePost: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/snack_bar', () => ({
|
||||
showBoRPostErrorSnackbar: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('UnrevealedBurnOnReadPost', () => {
|
||||
const mockPost = {
|
||||
id: 'post_id_123',
|
||||
} as PostModel;
|
||||
|
||||
const baseProps = {
|
||||
post: mockPost,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render button with correct text and icon', () => {
|
||||
renderWithIntlAndTheme(<UnrevealedBurnOnReadPost {...baseProps}/>);
|
||||
|
||||
const button = screen.getByText('View message');
|
||||
expect(button).toBeVisible();
|
||||
});
|
||||
|
||||
test('should call revealBoRPost when button is pressed', async () => {
|
||||
jest.mocked(revealBoRPost).mockResolvedValue({error: null});
|
||||
|
||||
renderWithIntlAndTheme(<UnrevealedBurnOnReadPost {...baseProps}/>);
|
||||
|
||||
const button = screen.getByText('View message');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(button);
|
||||
});
|
||||
|
||||
expect(revealBoRPost).toHaveBeenCalledWith('', 'post_id_123');
|
||||
expect(revealBoRPost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should handle successful reveal without error', async () => {
|
||||
jest.mocked(revealBoRPost).mockResolvedValue({error: null});
|
||||
|
||||
renderWithIntlAndTheme(<UnrevealedBurnOnReadPost {...baseProps}/>);
|
||||
|
||||
const button = screen.getByText('View message');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(button);
|
||||
});
|
||||
|
||||
expect(deletePost).not.toHaveBeenCalled();
|
||||
expect(showBoRPostErrorSnackbar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle all post reveal errors', async () => {
|
||||
for (const errorCode of BOR_ERROR_CODES) {
|
||||
// Clearing mocks to ensure each iteration runs with fresh mock state
|
||||
jest.clearAllMocks();
|
||||
|
||||
const error = {server_error_id: errorCode, message: `Post unrevealed error for code: ${errorCode}`};
|
||||
jest.mocked(revealBoRPost).mockResolvedValueOnce({error});
|
||||
|
||||
renderWithIntlAndTheme(<UnrevealedBurnOnReadPost {...baseProps}/>);
|
||||
const button = screen.getByText('View message');
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await act(async () => {
|
||||
fireEvent.press(button);
|
||||
});
|
||||
|
||||
expect(showBoRPostErrorSnackbar).toHaveBeenCalledWith(`Post unrevealed error for code: ${errorCode}`);
|
||||
expect(removePost).toHaveBeenCalledWith('', mockPost);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle non-400 error without deleting post', async () => {
|
||||
const error = {status_code: 500, message: 'Unexpected server error'};
|
||||
jest.mocked(revealBoRPost).mockResolvedValue({error});
|
||||
|
||||
renderWithIntlAndTheme(<UnrevealedBurnOnReadPost {...baseProps}/>);
|
||||
|
||||
const button = screen.getByText('View message');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(button);
|
||||
});
|
||||
|
||||
expect(removePost).not.toHaveBeenCalled();
|
||||
expect(showBoRPostErrorSnackbar).toHaveBeenCalledWith('Unexpected server error');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import {revealBoRPost} from '@actions/remote/post';
|
||||
import Button from '@components/button';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {PostModel} from '@database/models/server';
|
||||
import {getFullErrorMessage, getServerError} from '@utils/errors';
|
||||
import {showBoRPostErrorSnackbar} from '@utils/snack_bar';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import {BOR_ERROR_CODES} from './constants';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
buttonBackgroundStyle: {
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
height: 56,
|
||||
marginBottom: 8,
|
||||
},
|
||||
buttonTextStyle: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
post: PostModel;
|
||||
}
|
||||
|
||||
export default function UnrevealedBurnOnReadPost({post}: Props) {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const intl = useIntl();
|
||||
|
||||
const handleRevealPost = useCallback(async () => {
|
||||
const {error} = await revealBoRPost(serverUrl, post.id);
|
||||
if (error) {
|
||||
showBoRPostErrorSnackbar(getFullErrorMessage(error));
|
||||
|
||||
const serverError = getServerError(error);
|
||||
if (serverError && BOR_ERROR_CODES.includes(serverError)) {
|
||||
await removePost(serverUrl, post);
|
||||
}
|
||||
}
|
||||
|
||||
}, [serverUrl, post]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
text={intl.formatMessage({id: 'mobile.burn_on_read.placeholder', defaultMessage: 'View message'})}
|
||||
iconName='eye-outline'
|
||||
theme={theme}
|
||||
backgroundStyle={styles.buttonBackgroundStyle}
|
||||
textStyle={styles.buttonTextStyle}
|
||||
onPress={handleRevealPost}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const TIMER_REFRESH_INTERVAL_MS = 1000;
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {act} from '@testing-library/react-hooks';
|
||||
import {screen} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
import {advanceTimers, disableFakeTimers, enableFakeTimers} from '@test/timer_helpers';
|
||||
|
||||
import ExpiryCountdown from './index';
|
||||
|
||||
jest.mock('@utils/theme', () => ({
|
||||
changeOpacity: jest.fn().mockReturnValue('rgba(0,0,0,0.5)'),
|
||||
makeStyleSheetFromTheme: jest.fn().mockReturnValue(() => ({
|
||||
container: {},
|
||||
text: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('ExpiryCountdown', () => {
|
||||
beforeEach(() => {
|
||||
enableFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disableFakeTimers();
|
||||
});
|
||||
|
||||
test('should render countdown with correct time format', async () => {
|
||||
const futureTime = Date.now() + 3661000; // 1 hour, 1 minute, 1 second
|
||||
const mockOnExpiry = jest.fn();
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<ExpiryCountdown
|
||||
expiryTime={futureTime}
|
||||
onExpiry={mockOnExpiry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('1:01:01')).toBeVisible();
|
||||
|
||||
act(() => {
|
||||
advanceTimers(2000);
|
||||
});
|
||||
expect(screen.getByText('1:00:59')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should render countdown without hours when less than 1 hour remaining', () => {
|
||||
const futureTime = Date.now() + 125000; // 2 minutes, 5 seconds
|
||||
const mockOnExpiry = jest.fn();
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<ExpiryCountdown
|
||||
expiryTime={futureTime}
|
||||
onExpiry={mockOnExpiry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('2:05')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle already expired time', () => {
|
||||
const pastTime = Date.now() - 5000; // 5 seconds ago
|
||||
const mockOnExpiry = jest.fn();
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<ExpiryCountdown
|
||||
expiryTime={pastTime}
|
||||
onExpiry={mockOnExpiry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('0:00')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should call onExpiry callback when countdown reaches zero', async () => {
|
||||
const futureTime = Date.now() + 2000; // 2 seconds
|
||||
const mockOnExpiry = jest.fn();
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<ExpiryCountdown
|
||||
expiryTime={futureTime}
|
||||
onExpiry={mockOnExpiry}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
advanceTimers(2000);
|
||||
});
|
||||
expect(screen.getByText('0:00')).toBeVisible();
|
||||
expect(mockOnExpiry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
74
app/components/post_list/post/header/expiry_timer/index.tsx
Normal file
74
app/components/post_list/post/header/expiry_timer/index.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {formatTime} from '@utils/datetime';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import {TIMER_REFRESH_INTERVAL_MS} from './constants';
|
||||
|
||||
type Props = {
|
||||
expiryTime: number; // timestamp in ms
|
||||
onExpiry?: () => void;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
backgroundColor: changeOpacity(theme.dndIndicator, 0.12),
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
color: theme.dndIndicator,
|
||||
fontWeight: 600,
|
||||
},
|
||||
}));
|
||||
|
||||
const ExpiryCountdown: React.FC<Props> = ({expiryTime, onExpiry}) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(() => Math.max(0, expiryTime - Date.now()) / 1000);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
const remainingTime = Math.max(0, expiryTime - Date.now()) / 1000;
|
||||
setRemainingSeconds(remainingTime);
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
clearInterval(timer);
|
||||
onExpiry?.();
|
||||
}
|
||||
}, TIMER_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [expiryTime, onExpiry]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={style.container}
|
||||
testID='expiry-timer'
|
||||
>
|
||||
<CompassIcon
|
||||
name='fire'
|
||||
size={16}
|
||||
color={theme.dndIndicator}
|
||||
/>
|
||||
<Text style={style.text}>
|
||||
{formatTime(remainingSeconds)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpiryCountdown;
|
||||
|
||||
89
app/components/post_list/post/header/header.test.tsx
Normal file
89
app/components/post_list/post/header/header.test.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen} from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ExpiryCountdown from '@components/post_list/post/header/expiry_timer';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import Header from './header';
|
||||
|
||||
jest.mock('@components/compass_icon', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mocked(CompassIcon).mockImplementation(
|
||||
(props) => React.createElement('CompassIcon', {testID: `compass-icon${props.name ? '-' + props.name : ''}`, ...props}) as any,
|
||||
);
|
||||
|
||||
jest.mock('@components/post_list/post/header/expiry_timer', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mocked(ExpiryCountdown).mockImplementation(
|
||||
(props) => React.createElement('ExpiryCountdown', {testID: 'expiry-timer', ...props}) as any,
|
||||
);
|
||||
|
||||
describe('Header', () => {
|
||||
const currentUser = TestHelper.fakeUserModel();
|
||||
|
||||
const defaultProps = {
|
||||
commentCount: 0,
|
||||
enablePostUsernameOverride: false,
|
||||
isAutoResponse: false,
|
||||
isCustomStatusEnabled: false,
|
||||
isEphemeral: false,
|
||||
isMilitaryTime: false,
|
||||
isPendingOrFailed: false,
|
||||
isSystemPost: false,
|
||||
isWebHook: false,
|
||||
location: 'About' as const,
|
||||
showPostPriority: false,
|
||||
teammateNameDisplay: '',
|
||||
hideGuestTags: false,
|
||||
currentUser,
|
||||
};
|
||||
|
||||
it('Should show BoR icon for own BoR post', () => {
|
||||
const ownBoRPost = TestHelper.fakePostModel({
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
userId: currentUser.id,
|
||||
metadata: {
|
||||
|
||||
// missing expire_at key indicates unrevealed BoR post
|
||||
},
|
||||
});
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
post={ownBoRPost}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('compass-icon-fire')).toBeVisible();
|
||||
expect(screen.queryByTestId('expiry-timer')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('Should show BoR countdown for revealed BoR post', () => {
|
||||
const ownBoRPost = TestHelper.fakePostModel({
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
metadata: {
|
||||
expire_at: Date.now() + 60000,
|
||||
},
|
||||
});
|
||||
|
||||
renderWithIntlAndTheme(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
post={ownBoRPost}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('expiry-timer')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,20 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import {removePost} from '@actions/local/post';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import FormattedTime from '@components/formatted_time';
|
||||
import ExpiryTimer from '@components/post_list/post/header/expiry_timer';
|
||||
import PostPriorityLabel from '@components/post_priority/post_priority_label';
|
||||
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 {postUserDisplayName} from '@utils/post';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {ensureString} from '@utils/types';
|
||||
|
|
@ -96,6 +101,16 @@ 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), [currentUser, post]);
|
||||
const showBoRIcon = isUnrevealedPost || ownBoRPost;
|
||||
const borExpireAt = post.metadata?.expire_at;
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const onBoRPostExpiry = useCallback(async () => {
|
||||
await removePost(serverUrl, post);
|
||||
}, [post, serverUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[style.container, pendingPostStyle]}>
|
||||
|
|
@ -141,6 +156,20 @@ const Header = (props: HeaderProps) => {
|
|||
label={post.metadata.priority.priority}
|
||||
/>
|
||||
)}
|
||||
{showBoRIcon &&
|
||||
<CompassIcon
|
||||
name='fire'
|
||||
size={16}
|
||||
color={theme.dndIndicator}
|
||||
/>
|
||||
}
|
||||
{
|
||||
!showBoRIcon && Boolean(borExpireAt) &&
|
||||
<ExpiryTimer
|
||||
expiryTime={borExpireAt as number}
|
||||
onExpiry={onBoRPostExpiry}
|
||||
/>
|
||||
}
|
||||
{!isCRTEnabled && showReply && commentCount > 0 &&
|
||||
<HeaderReply
|
||||
commentCount={commentCount}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {queryReactionsForPost} from '@queries/servers/reaction';
|
|||
import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role';
|
||||
import {observeThreadById} from '@queries/servers/thread';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {isBoRPost} from '@utils/bor';
|
||||
import {areConsecutivePosts, isPostEphemeral} from '@utils/post';
|
||||
|
||||
import Post from './post';
|
||||
|
|
@ -124,7 +125,9 @@ const withPost = withObservables(
|
|||
|
||||
const hasReplies = observeHasReplies(database, post);//Need to review and understand
|
||||
|
||||
const isConsecutivePost = author.pipe(
|
||||
// Don't combine consecutive Burn on Read posts as we want each BoR post
|
||||
// to display its header to allow displaying the remaining time.
|
||||
const isConsecutivePost = isBoRPost(post) ? of$(false) : author.pipe(
|
||||
switchMap((user) => of$(Boolean(post && previousPost && !user?.isBot && areConsecutivePosts(post, previousPost)))),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
import React, {type ComponentProps} from 'react';
|
||||
|
||||
import UnrevealedBurnOnReadPost from '@components/post_list/post/burn_on_read/unrevealed';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
|
|
@ -15,6 +17,7 @@ import type {Database} from '@nozbe/watermelondb';
|
|||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
jest.mock('@managers/performance_metrics_manager');
|
||||
jest.mock('@components/post_list/post/burn_on_read/unrevealed');
|
||||
|
||||
describe('performance metrics', () => {
|
||||
let database: Database;
|
||||
|
|
@ -78,4 +81,21 @@ describe('performance metrics', () => {
|
|||
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render unrevealed post correctly', async () => {
|
||||
const props = getBaseProps();
|
||||
const unrevealedBoRPost = TestHelper.fakePostModel({
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {
|
||||
expire_at: Date.now() + 1000000,
|
||||
},
|
||||
});
|
||||
|
||||
props.post = unrevealedBoRPost;
|
||||
|
||||
renderWithEverything(<Post {...props}/>, {database, serverUrl});
|
||||
await waitFor(() => {
|
||||
expect(UnrevealedBurnOnReadPost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {showPermalink} from '@actions/remote/permalink';
|
|||
import {fetchAndSwitchToThread} from '@actions/remote/thread';
|
||||
import CallsCustomMessage from '@calls/components/calls_custom_message';
|
||||
import {isCallsCustomMessage} from '@calls/utils';
|
||||
import UnrevealedBurnOnReadPost from '@components/post_list/post/burn_on_read/unrevealed';
|
||||
import SystemAvatar from '@components/system_avatar';
|
||||
import SystemHeader from '@components/system_header';
|
||||
import {POST_TIME_TO_FAIL} from '@constants/post';
|
||||
|
|
@ -22,6 +23,7 @@ import {useTheme} from '@context/theme';
|
|||
import {useIsTablet} from '@hooks/device';
|
||||
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||
import {openAsBottomSheet} from '@screens/navigation';
|
||||
import {isBoRPost, isUnrevealedBoRPost} from '@utils/bor';
|
||||
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
|
||||
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
|
@ -159,6 +161,8 @@ const Post = ({
|
|||
const isFailed = isPostFailed(post);
|
||||
const isSystemPost = isSystemMessage(post);
|
||||
const isCallsPost = isCallsCustomMessage(post);
|
||||
const borPost = isBoRPost(post);
|
||||
const isUnrevealedPost = isUnrevealedBoRPost(post);
|
||||
const isAgentPostType = isAgentPost(post);
|
||||
const hasBeenDeleted = (post.deleteAt !== 0);
|
||||
const isWebHook = isFromWebhook(post);
|
||||
|
|
@ -190,7 +194,8 @@ const Post = ({
|
|||
if (isEphemeral || hasBeenDeleted) {
|
||||
removePost(serverUrl, post);
|
||||
} else if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) {
|
||||
if ([Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
|
||||
// BoR posts cannot have replies, so don't open threads screen for them
|
||||
if (!borPost && [Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
|
||||
const postRootId = post.rootId || post.id;
|
||||
fetchAndSwitchToThread(serverUrl, postRootId);
|
||||
}
|
||||
|
|
@ -199,10 +204,7 @@ const Post = ({
|
|||
setTimeout(() => {
|
||||
pressDetected.current = false;
|
||||
}, 300);
|
||||
}, [
|
||||
hasBeenDeleted, isAutoResponder, isEphemeral,
|
||||
isPendingOrFailed, isSystemPost, location, serverUrl, post,
|
||||
]);
|
||||
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost]);
|
||||
|
||||
const handlePress = useHideExtraKeyboardIfNeeded(() => {
|
||||
pressDetected.current = true;
|
||||
|
|
@ -363,6 +365,10 @@ const Post = ({
|
|||
joiningChannelId={null}
|
||||
/>
|
||||
);
|
||||
} else if (isUnrevealedPost) {
|
||||
body = (
|
||||
<UnrevealedBurnOnReadPost post={post}/>
|
||||
);
|
||||
} else if (isAgentPostType && !hasBeenDeleted) {
|
||||
body = (
|
||||
<AgentPost
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ describe('ScheduledPostIndicator', () => {
|
|||
};
|
||||
|
||||
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
|
||||
expect(screen.getByText(/2 scheduled messages in channel/i)).toBeTruthy();
|
||||
expect(screen.getByText(/2 scheduled messages in channel/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should render thread message when isThread is true', () => {
|
||||
|
|
@ -63,7 +63,7 @@ describe('ScheduledPostIndicator', () => {
|
|||
};
|
||||
|
||||
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
|
||||
expect(screen.getByText(/2 scheduled messages in thread/i)).toBeTruthy();
|
||||
expect(screen.getByText(/2 scheduled messages in thread/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should render correct message when there is only one scheduled post', () => {
|
||||
|
|
@ -75,7 +75,7 @@ describe('ScheduledPostIndicator', () => {
|
|||
};
|
||||
|
||||
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
|
||||
expect(screen.getByText(/1 scheduled message in thread/i)).toBeTruthy();
|
||||
expect(screen.getByText(/1 scheduled message in thread/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle see all scheduled posts click', async () => {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export const SYSTEM_IDENTIFIERS = {
|
|||
TEAM_HISTORY: 'teamHistory',
|
||||
WEBSOCKET: 'WebSocket',
|
||||
PLAYBOOKS_VERSION: 'playbooks_version',
|
||||
LAST_BOR_POST_CLEANUP_RUN: 'lastBoRPostCleanupRun',
|
||||
};
|
||||
|
||||
export const GLOBAL_IDENTIFIERS = {
|
||||
|
|
|
|||
|
|
@ -35,8 +35,11 @@ export const PostTypes = {
|
|||
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
|
||||
CUSTOM_CALLS: 'custom_calls',
|
||||
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
|
||||
|
||||
CUSTOM_LLMBOT: 'custom_llmbot',
|
||||
CUSTOM_LLM_POSTBACK: 'custom_llm_postback',
|
||||
|
||||
BURN_ON_READ: 'burn_on_read',
|
||||
} as const;
|
||||
|
||||
export const PostPriorityColors = {
|
||||
|
|
@ -52,6 +55,8 @@ export enum PostPriorityType {
|
|||
|
||||
export const POST_TIME_TO_FAIL = toMilliseconds({seconds: 10});
|
||||
|
||||
export const BOR_POST_CLEANUP_MIN_RUN_INTERVAL = toMilliseconds({minutes: 15});
|
||||
|
||||
export default {
|
||||
POST_COLLAPSE_TIMEOUT: toMilliseconds({minutes: 5}),
|
||||
POST_TYPES: PostTypes,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export const SNACK_BAR_TYPE = keyMirror({
|
|||
RESCHEDULED_POST: null,
|
||||
DELETE_SCHEDULED_POST_ERROR: null,
|
||||
PLAYBOOK_ERROR: null,
|
||||
BOR_POST_EXPIRED: null,
|
||||
});
|
||||
|
||||
export const MESSAGE_TYPE = {
|
||||
|
|
@ -117,6 +118,10 @@ const messages = defineMessages({
|
|||
id: 'snack.bar.playbook.error',
|
||||
defaultMessage: 'Unable to perform action. Please try again later.',
|
||||
},
|
||||
BOR_POST_EXPIRED: {
|
||||
id: 'snack.bar.bor_post_expired.error',
|
||||
defaultMessage: 'This burn-on-read post has expired and can no longer be revealed.',
|
||||
},
|
||||
});
|
||||
|
||||
export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
|
||||
|
|
@ -217,6 +222,12 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
|
|||
canUndo: false,
|
||||
type: MESSAGE_TYPE.ERROR,
|
||||
},
|
||||
BOR_POST_EXPIRED: {
|
||||
message: messages.BOR_POST_EXPIRED,
|
||||
iconName: 'alert-outline',
|
||||
canUndo: false,
|
||||
type: MESSAGE_TYPE.ERROR,
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ const WebsocketEvents = {
|
|||
|
||||
// Agents
|
||||
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
|
||||
|
||||
// Burn on Read
|
||||
BOR_POST_REVEALED: 'post_revealed',
|
||||
};
|
||||
|
||||
export default WebsocketEvents;
|
||||
|
|
|
|||
|
|
@ -752,6 +752,67 @@ describe('*** Operator: Post Handlers tests ***', () => {
|
|||
expect(files).toHaveLength(1);
|
||||
expect(files[0].id).toBe(uploadedFiles[0].id);
|
||||
});
|
||||
|
||||
it('=> HandlePosts: should update unrevealed burn-on-read post when it becomes revealed', async () => {
|
||||
const spyOnProcessRecords = jest.spyOn(operator, 'processRecords');
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Create an unrevealed burn-on-read post
|
||||
const unrevealedBorPost: 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: '',
|
||||
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 unrevealed post
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [unrevealedBorPost.id],
|
||||
posts: [unrevealedBorPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// Now create a revealed version of the same post
|
||||
const revealedBorPost: Post = {
|
||||
...unrevealedBorPost,
|
||||
message: 'This is the revealed message',
|
||||
metadata: {expire_at: now + 1000000},
|
||||
};
|
||||
|
||||
// Handle the revealed post
|
||||
await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
|
||||
order: [revealedBorPost.id],
|
||||
posts: [revealedBorPost],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
// Verify that processRecords was called with the shouldUpdate function that handles BoR posts
|
||||
expect(spyOnProcessRecords).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createOrUpdateRawValues: [revealedBorPost],
|
||||
shouldUpdate: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('*** Operator: merge chunks ***', () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {Q} from '@nozbe/watermelondb';
|
|||
|
||||
import {ActionType} from '@constants';
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import {buildDraftKey} from '@database/operator/server_data_operator/comparators';
|
||||
import {
|
||||
transformDraftRecord,
|
||||
|
|
@ -19,6 +20,7 @@ import {queryScheduledPostsForTeam} from '@queries/servers/scheduled_post';
|
|||
import {getCurrentTeamId} from '@queries/servers/system';
|
||||
import FileModel from '@typings/database/models/servers/file';
|
||||
import ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
|
||||
import {isUnrevealedBoRPost} from '@utils/bor';
|
||||
import {safeParseJSON} from '@utils/helpers';
|
||||
import {logWarning} from '@utils/log';
|
||||
|
||||
|
|
@ -364,7 +366,14 @@ const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
|
|||
deleteRawValues: pendingPostsToDelete,
|
||||
tableName,
|
||||
fieldName: 'id',
|
||||
shouldUpdate: (e: PostModel, n: Post) => n.update_at > e.updateAt,
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return n.update_at > e.updateAt;
|
||||
},
|
||||
}));
|
||||
|
||||
const preparedPosts = (await this.prepareRecords({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {License} from '@constants';
|
|||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
|
||||
import {observeIsMinimumLicenseTier, observeReportAProblemMetadata} from './system';
|
||||
import {observeIsMinimumLicenseTier, observeReportAProblemMetadata, getLastBoRPostCleanupRun} from './system';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
|
@ -84,6 +84,66 @@ describe('observeReportAProblemMetadata', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getLastBoRPostCleanupRun', () => {
|
||||
const serverUrl = 'baseHandler.test.com';
|
||||
let database: Database;
|
||||
let operator: ServerDataOperator;
|
||||
|
||||
beforeEach(async () => {
|
||||
await DatabaseManager.init([serverUrl]);
|
||||
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
database = serverDatabaseAndOperator.database;
|
||||
operator = serverDatabaseAndOperator.operator;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await DatabaseManager.destroyServerDatabase(serverUrl);
|
||||
});
|
||||
|
||||
it('should return 0 when no BoR post cleanup run record exists', async () => {
|
||||
const result = await getLastBoRPostCleanupRun(database);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should return the stored timestamp when BoR post cleanup run record exists', async () => {
|
||||
const timestamp = 1640995200000; // Example timestamp
|
||||
|
||||
await operator.handleSystem({
|
||||
systems: [
|
||||
{id: SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN, value: timestamp},
|
||||
],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await getLastBoRPostCleanupRun(database);
|
||||
expect(result).toBe(timestamp);
|
||||
});
|
||||
|
||||
it('should return 0 when stored value is falsy', async () => {
|
||||
await operator.handleSystem({
|
||||
systems: [
|
||||
{id: SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN, value: 0},
|
||||
],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await getLastBoRPostCleanupRun(database);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 when stored value is null or undefined', async () => {
|
||||
await operator.handleSystem({
|
||||
systems: [
|
||||
{id: SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN, value: null},
|
||||
],
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
const result = await getLastBoRPostCleanupRun(database);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('observeIsMinimumLicenseTier', () => {
|
||||
const serverUrl = 'baseHandler.test.com';
|
||||
let database: Database;
|
||||
|
|
|
|||
|
|
@ -178,6 +178,15 @@ export const getLastGlobalDataRetentionRun = async (database: Database) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const getLastBoRPostCleanupRun = async (database: Database) => {
|
||||
try {
|
||||
const data = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN);
|
||||
return data?.value || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getGlobalDataRetentionPolicy = async (database: Database) => {
|
||||
try {
|
||||
const data = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.DATA_RETENTION_POLICIES);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
|||
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
|
||||
import {translateYConfig} from '@hooks/gallery';
|
||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||
import {formatTime} from '@utils/datetime';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import {useStateFromSharedValue} from '../hooks';
|
||||
|
|
@ -48,18 +49,6 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
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);
|
||||
|
||||
const hh = h > 0 ? `${h}:` : '';
|
||||
const mm = h > 0 ? `${m.toString().padStart(2, '0')}` : `${m}`;
|
||||
const ss = s.toString().padStart(2, '0');
|
||||
|
||||
return `${hh}${mm}:${ss}`;
|
||||
};
|
||||
|
||||
const BottomControls: React.FC<BottomControlsProps> = ({
|
||||
currentTime,
|
||||
duration,
|
||||
|
|
|
|||
138
app/screens/post_options/index.test.tsx
Normal file
138
app/screens/post_options/index.test.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {screen, waitFor} from '@testing-library/react-native';
|
||||
|
||||
import {PostTypes} from '@constants/post';
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import PostOptions from './index';
|
||||
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
const serverUrl = 'https://www.community.mattermost.com';
|
||||
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const Reanimated = require('react-native-reanimated/mock');
|
||||
return {
|
||||
...Reanimated,
|
||||
useReducedMotion: jest.fn(() => 'never'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PostOptions', () => {
|
||||
let database: Database;
|
||||
beforeAll(async () => {
|
||||
const server = await TestHelper.setupServerDatabase();
|
||||
database = server.database;
|
||||
});
|
||||
|
||||
it('should show limited options for unrevealed BoR post', async () => {
|
||||
const unrevealedBoRPost = TestHelper.fakePostModel({
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
channelId: TestHelper.basicChannel!.id,
|
||||
userId: TestHelper.generateId(),
|
||||
metadata: {
|
||||
|
||||
// missing expire_at key indicates unrevealed BoR post
|
||||
},
|
||||
});
|
||||
|
||||
renderWithEverything(
|
||||
<PostOptions
|
||||
post={unrevealedBoRPost}
|
||||
serverUrl={serverUrl}
|
||||
showAddReaction={true}
|
||||
sourceScreen={'DraftScheduledPostOptions'}
|
||||
componentId={'DraftScheduledPostOptions'}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mark as Unread')).toBeVisible();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Copy Link')).toBeVisible();
|
||||
expect(screen.queryByText('Save')).toBeVisible();
|
||||
|
||||
expect(screen.queryByText('Pin to Channel')).not.toBeVisible();
|
||||
expect(screen.queryByText('Copy Text')).not.toBeVisible();
|
||||
expect(screen.queryByText('Edit')).not.toBeVisible();
|
||||
expect(screen.queryByText('Reply')).not.toBeVisible();
|
||||
expect(screen.queryByText('Follow Message')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should show limited options for revealed BoR post', async () => {
|
||||
const unrevealedBoRPost = TestHelper.fakePostModel({
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
channelId: TestHelper.basicChannel!.id,
|
||||
userId: TestHelper.generateId(),
|
||||
message: 'This is a revealed BoR post',
|
||||
metadata: {
|
||||
expire_at: Date.now() + 1000000,
|
||||
},
|
||||
props: {
|
||||
expire_at: Date.now() + 1000000000,
|
||||
},
|
||||
});
|
||||
|
||||
renderWithEverything(
|
||||
<PostOptions
|
||||
post={unrevealedBoRPost}
|
||||
serverUrl={serverUrl}
|
||||
showAddReaction={true}
|
||||
sourceScreen={'DraftScheduledPostOptions'}
|
||||
componentId={'DraftScheduledPostOptions'}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mark as Unread')).toBeVisible();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Copy Link')).toBeVisible();
|
||||
expect(screen.queryByText('Save')).toBeVisible();
|
||||
|
||||
expect(screen.queryByText('Copy Text')).not.toBeVisible();
|
||||
expect(screen.queryByText('Pin to Channel')).not.toBeVisible();
|
||||
expect(screen.queryByText('Edit')).not.toBeVisible();
|
||||
expect(screen.queryByText('Reply')).not.toBeVisible();
|
||||
expect(screen.queryByText('Follow Message')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should show all options for regular post', async () => {
|
||||
const unrevealedBoRPost = TestHelper.fakePostModel({
|
||||
channelId: TestHelper.basicChannel!.id,
|
||||
userId: TestHelper.basicUser!.id,
|
||||
message: 'This is a regular post',
|
||||
});
|
||||
|
||||
renderWithEverything(
|
||||
<PostOptions
|
||||
post={unrevealedBoRPost}
|
||||
serverUrl={serverUrl}
|
||||
showAddReaction={true}
|
||||
sourceScreen={'DraftScheduledPostOptions'}
|
||||
componentId={'DraftScheduledPostOptions'}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Copy Link')).toBeVisible();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Save')).toBeVisible();
|
||||
expect(screen.queryByText('Pin to Channel')).toBeVisible();
|
||||
expect(screen.queryByText('Copy Text')).toBeVisible();
|
||||
expect(screen.queryByText('Reply')).toBeVisible();
|
||||
expect(screen.queryByText('Edit')).toBeVisible();
|
||||
|
||||
// cannot mark own post as unread in the mobile app.
|
||||
expect(screen.queryByText('Mark as Unread')).not.toBeVisible();
|
||||
expect(screen.queryByText('Follow Message')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import {observePermissionForChannel, observePermissionForPost} from '@queries/se
|
|||
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 {toMilliseconds} from '@utils/datetime';
|
||||
import {isMinimumServerVersion} from '@utils/helpers';
|
||||
import {isFromWebhook, isSystemMessage} from '@utils/post';
|
||||
|
|
@ -83,6 +84,8 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
|||
const serverVersion = observeConfigValue(database, 'Version');
|
||||
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)));
|
||||
|
|
@ -107,17 +110,17 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
|||
}),
|
||||
);
|
||||
|
||||
const canReply = combineLatest([channelIsArchived, channelIsReadOnly, canPostPermission]).pipe(switchMap(([isArchived, isReadOnly, canPost]) => {
|
||||
const canReply = borPost ? of$(false) : combineLatest([channelIsArchived, channelIsReadOnly, canPostPermission]).pipe(switchMap(([isArchived, isReadOnly, canPost]) => {
|
||||
return of$(!isArchived && !isReadOnly && sourceScreen !== Screens.THREAD && !isSystemMessage(post) && canPost);
|
||||
}));
|
||||
|
||||
const canPin = combineLatest([channelIsArchived, channelIsReadOnly]).pipe(switchMap(([isArchived, isReadOnly]) => {
|
||||
const canPin = borPost ? of$(false) : combineLatest([channelIsArchived, channelIsReadOnly]).pipe(switchMap(([isArchived, isReadOnly]) => {
|
||||
return of$(!isSystemMessage(post) && !isArchived && !isReadOnly);
|
||||
}));
|
||||
|
||||
const isSaved = observePostSaved(database, post.id);
|
||||
|
||||
const canEdit = combineLatest([postEditTimeLimit, isLicensed, channel, currentUser, channelIsArchived, channelIsReadOnly, canEditUntil, canPostPermission]).pipe(
|
||||
const canEdit = borPost ? of$(false) : combineLatest([postEditTimeLimit, isLicensed, channel, currentUser, channelIsArchived, channelIsReadOnly, canEditUntil, canPostPermission]).pipe(
|
||||
switchMap(([lt, ls, c, u, isArchived, isReadOnly, until, canPost]) => {
|
||||
const isOwner = u?.id === post.userId;
|
||||
const canEditPostPermission = (c && u) ? observeCanEditPost(database, isOwner, post, lt, ls, c, u) : of$(false);
|
||||
|
|
@ -137,7 +140,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
|||
)),
|
||||
);
|
||||
|
||||
const canAddReaction = combineLatest([hasAddReactionPermission, channelIsReadOnly, isUnderMaxAllowedReactions, channelIsArchived]).pipe(
|
||||
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);
|
||||
}),
|
||||
|
|
@ -163,6 +166,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
|||
post,
|
||||
thread,
|
||||
bindings,
|
||||
isBoRPost: of$(borPost),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -47,12 +47,14 @@ type PostOptionsProps = {
|
|||
componentId: AvailableScreens;
|
||||
bindings: AppBinding[];
|
||||
serverUrl: string;
|
||||
isBoRPost?: boolean;
|
||||
};
|
||||
const PostOptions = ({
|
||||
canAddReaction, canDelete, canEdit,
|
||||
canMarkAsUnread, canPin, canReply,
|
||||
combinedPost, componentId, isSaved,
|
||||
sourceScreen, post, thread, bindings, serverUrl,
|
||||
isBoRPost,
|
||||
}: PostOptionsProps) => {
|
||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||
const isTablet = useIsTablet();
|
||||
|
|
@ -68,7 +70,7 @@ const PostOptions = ({
|
|||
const isSystemPost = isSystemMessage(post);
|
||||
|
||||
const canCopyPermalink = !isSystemPost && managedConfig?.copyAndPasteProtection !== 'true';
|
||||
const canCopyText = canCopyPermalink && post.message;
|
||||
const canCopyText = canCopyPermalink && post.message && !isBoRPost;
|
||||
|
||||
const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread);
|
||||
const shouldShowBindings = bindings.length > 0 && !isSystemPost;
|
||||
|
|
|
|||
208
app/utils/bor.test.ts
Normal file
208
app/utils/bor.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Post} from '@constants';
|
||||
|
||||
import {
|
||||
isBoRPost,
|
||||
isUnrevealedBoRPost,
|
||||
isOwnBoRPost,
|
||||
isExpiredBoRPost,
|
||||
} from './bor';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
describe('BoR utility functions', () => {
|
||||
const mockUser: UserModel = {
|
||||
id: 'user123',
|
||||
} as UserModel;
|
||||
|
||||
describe('isBoRPost', () => {
|
||||
it('should return true for BoR posts', () => {
|
||||
const borPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
} as PostModel;
|
||||
|
||||
expect(isBoRPost(borPost)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-BoR posts', () => {
|
||||
const regularPost: PostModel = {
|
||||
type: Post.POST_TYPES.CHANNEL_DELETED,
|
||||
} as PostModel;
|
||||
|
||||
expect(isBoRPost(regularPost)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for posts without type', () => {
|
||||
const postWithoutType: PostModel = {} as PostModel;
|
||||
|
||||
expect(isBoRPost(postWithoutType)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnrevealedBoRPost', () => {
|
||||
it('should return true for BoR posts without expire_at in metadata', () => {
|
||||
const unrevealedBorPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
metadata: {},
|
||||
} as PostModel;
|
||||
|
||||
expect(isUnrevealedBoRPost(unrevealedBorPost)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for BoR posts with null metadata', () => {
|
||||
const borPostWithNullMetadata: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
metadata: null,
|
||||
} as PostModel;
|
||||
|
||||
expect(isUnrevealedBoRPost(borPostWithNullMetadata)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for BoR posts with expire_at in metadata', () => {
|
||||
const revealedBorPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
metadata: {
|
||||
expire_at: Date.now() + 10000,
|
||||
},
|
||||
} as PostModel;
|
||||
|
||||
expect(isUnrevealedBoRPost(revealedBorPost)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for BoR posts with expire_at in the past', () => {
|
||||
const revealedBorPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
metadata: {
|
||||
expire_at: Date.now() - 10000,
|
||||
},
|
||||
} as PostModel;
|
||||
|
||||
expect(isUnrevealedBoRPost(revealedBorPost)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-BoR posts', () => {
|
||||
const regularPost: PostModel = {
|
||||
type: '',
|
||||
metadata: {},
|
||||
} as PostModel;
|
||||
|
||||
expect(isUnrevealedBoRPost(regularPost)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOwnBoRPost', () => {
|
||||
it('should return true for BoR posts owned by current user', () => {
|
||||
const ownBorPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
userId: 'user123',
|
||||
} as PostModel;
|
||||
|
||||
expect(isOwnBoRPost(ownBorPost, mockUser)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for BoR posts not owned by current user', () => {
|
||||
const othersBorPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
userId: 'user456',
|
||||
} as PostModel;
|
||||
|
||||
expect(isOwnBoRPost(othersBorPost, mockUser)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-BoR posts', () => {
|
||||
const ownRegularPost: PostModel = {
|
||||
type: '',
|
||||
userId: 'user123',
|
||||
} as PostModel;
|
||||
|
||||
expect(isOwnBoRPost(ownRegularPost, mockUser)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no current user is provided', () => {
|
||||
const borPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
userId: 'user123',
|
||||
} as PostModel;
|
||||
|
||||
expect(isOwnBoRPost(borPost)).toBe(false);
|
||||
expect(isOwnBoRPost(borPost, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpiredBoRPost', () => {
|
||||
const pastTime = Date.now() - 10000;
|
||||
const futureTime = Date.now() + 10000;
|
||||
|
||||
it('should return true for BoR posts expired for all users (props.expire_at)', () => {
|
||||
const expiredBorPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
props: {
|
||||
expire_at: pastTime,
|
||||
} as PostMetadata,
|
||||
} as PostModel;
|
||||
|
||||
expect(isExpiredBoRPost(expiredBorPost)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for BoR posts expired for current user (metadata.expire_at)', () => {
|
||||
const expiredBorPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
metadata: {
|
||||
expire_at: pastTime,
|
||||
},
|
||||
} as PostModel;
|
||||
|
||||
expect(isExpiredBoRPost(expiredBorPost)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for BoR posts not yet expired', () => {
|
||||
const activeBorPost: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
props: {
|
||||
expire_at: futureTime,
|
||||
} as Record<string, unknown>,
|
||||
metadata: {
|
||||
expire_at: futureTime,
|
||||
} as PostMetadata,
|
||||
} as PostModel;
|
||||
|
||||
expect(isExpiredBoRPost(activeBorPost)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-BoR posts even with expired timestamps', () => {
|
||||
const expiredRegularPost: PostModel = {
|
||||
type: '',
|
||||
props: {
|
||||
expire_at: pastTime,
|
||||
} as Record<string, unknown>,
|
||||
metadata: {
|
||||
expire_at: pastTime,
|
||||
} as PostMetadata,
|
||||
} as PostModel;
|
||||
|
||||
expect(isExpiredBoRPost(expiredRegularPost)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for BoR posts without expiration data', () => {
|
||||
const borPostWithoutExpiration: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
} as PostModel;
|
||||
|
||||
expect(isExpiredBoRPost(borPostWithoutExpiration)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle string expire_at values in props', () => {
|
||||
const expiredBorPostWithStringTime: PostModel = {
|
||||
type: Post.POST_TYPES.BURN_ON_READ,
|
||||
props: {
|
||||
expire_at: pastTime.toString(),
|
||||
} as Record<string, unknown>,
|
||||
} as PostModel;
|
||||
|
||||
expect(isExpiredBoRPost(expiredBorPostWithStringTime)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
app/utils/bor.ts
Normal file
32
app/utils/bor.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Post} from '@constants';
|
||||
import {ensureNumber} from '@utils/types';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
export function isBoRPost(post: PostModel | Post): boolean {
|
||||
return Boolean(post.type && post.type === Post.POST_TYPES.BURN_ON_READ);
|
||||
}
|
||||
|
||||
export function isUnrevealedBoRPost(post: Post | PostModel): boolean {
|
||||
return isBoRPost(post) && Boolean(!post.metadata?.expire_at);
|
||||
}
|
||||
|
||||
export function isOwnBoRPost(post: PostModel, currentUser?: UserModel): boolean {
|
||||
return isBoRPost(post) && Boolean(currentUser && post.userId === currentUser.id);
|
||||
}
|
||||
|
||||
function isBoRPostExpiredForMe(post: PostModel): boolean {
|
||||
return Boolean(post.metadata?.expire_at) && post.metadata!.expire_at! < Date.now();
|
||||
}
|
||||
|
||||
function isBorPostExpiredForAll(post: PostModel): boolean {
|
||||
return Boolean(post.props?.expire_at) && ensureNumber(post.props!.expire_at!) < Date.now();
|
||||
}
|
||||
|
||||
export function isExpiredBoRPost(post: PostModel): boolean {
|
||||
return isBoRPost(post) && (isBorPostExpiredForAll(post) || isBoRPostExpiredForMe(post));
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getReadableTimestamp, isSameDate, isSameMonth, isSameYear, isToday, isYesterday} from './datetime';
|
||||
import {formatTime, getReadableTimestamp, isSameDate, isSameMonth, isSameYear, isToday, isYesterday} from './datetime';
|
||||
|
||||
describe('Datetime', () => {
|
||||
test('isSameDate (isSameMonth / isSameYear)', () => {
|
||||
|
|
@ -85,3 +85,43 @@ describe('getReadableTimestamp', () => {
|
|||
expect(deResult).toBe('15. Juni, 8:00 AM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('should format seconds only', () => {
|
||||
expect(formatTime(30)).toBe('0:30');
|
||||
expect(formatTime(5)).toBe('0:05');
|
||||
expect(formatTime(59)).toBe('0:59');
|
||||
});
|
||||
|
||||
it('should format minutes and seconds', () => {
|
||||
expect(formatTime(90)).toBe('1:30');
|
||||
expect(formatTime(125)).toBe('2:05');
|
||||
expect(formatTime(3599)).toBe('59:59');
|
||||
});
|
||||
|
||||
it('should format hours, minutes, and seconds', () => {
|
||||
expect(formatTime(3600)).toBe('1:00:00');
|
||||
expect(formatTime(3661)).toBe('1:01:01');
|
||||
expect(formatTime(7325)).toBe('2:02:05');
|
||||
expect(formatTime(36000)).toBe('10:00:00');
|
||||
});
|
||||
|
||||
it('should handle zero seconds', () => {
|
||||
expect(formatTime(0)).toBe('0:00');
|
||||
});
|
||||
|
||||
it('should handle negative values by treating them as zero', () => {
|
||||
expect(formatTime(-30)).toBe('0:00');
|
||||
expect(formatTime(-3600)).toBe('0:00');
|
||||
});
|
||||
|
||||
it('should pad minutes with zero when hours are present', () => {
|
||||
expect(formatTime(3605)).toBe('1:00:05');
|
||||
expect(formatTime(3665)).toBe('1:01:05');
|
||||
});
|
||||
|
||||
it('should not pad minutes with zero when no hours', () => {
|
||||
expect(formatTime(65)).toBe('1:05');
|
||||
expect(formatTime(605)).toBe('10:05');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,3 +55,15 @@ export function getReadableTimestamp(timestamp: number, timeZone: string, isMili
|
|||
|
||||
return date.toLocaleString(currentUserLocale, options);
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number) {
|
||||
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);
|
||||
|
||||
const hh = h > 0 ? `${h}:` : '';
|
||||
const mm = h > 0 ? `${m.toString().padStart(2, '0')}` : `${m}`;
|
||||
const ss = s.toString().padStart(2, '0');
|
||||
|
||||
return `${hh}${mm}:${ss}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Post} from '@constants';
|
||||
import {PostTypes} from '@constants/post';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import {
|
||||
selectOrderedPostsWithPrevAndNext,
|
||||
|
|
@ -13,6 +15,7 @@ import {
|
|||
shouldFilterJoinLeavePost,
|
||||
} from '.';
|
||||
|
||||
import type {PostListItem} from '@typings/components/post_list';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
const mockPostModel = (overrides: Partial<PostModel> = {}): PostModel => ({
|
||||
|
|
@ -267,6 +270,39 @@ describe('selectOrderedPosts', () => {
|
|||
// The second post should not be marked as saved
|
||||
expect(typeof result[1].value === 'object' && result[1].value.isSaved).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter out expired BoR posts', () => {
|
||||
const now = Date.now();
|
||||
|
||||
const borPostExpiredForAll = TestHelper.fakePostModel({
|
||||
id: 'postid1',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now - 10000},
|
||||
});
|
||||
|
||||
const borPostExpiredForMe = TestHelper.fakePostModel({
|
||||
id: 'postid2',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now + 100000},
|
||||
metadata: {expire_at: now - 10000},
|
||||
});
|
||||
|
||||
const unrevealedBoRPost = TestHelper.fakePostModel({
|
||||
id: 'postid3',
|
||||
type: PostTypes.BURN_ON_READ,
|
||||
props: {expire_at: now + 10000},
|
||||
});
|
||||
|
||||
const result = selectOrderedPosts([borPostExpiredForAll, borPostExpiredForMe, unrevealedBoRPost], lastViewedAt, true, currentUserId, currentUsername, showJoinLeave, currentTimezone, isThreadScreen, savedPostIds);
|
||||
|
||||
// Both BoR posts should be filtered out
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
expect(result[0].type).toBe('post');
|
||||
expect(result[1].type).toBe('date');
|
||||
|
||||
expect((result[0] as PostListItem).value.currentPost.id).toBe(unrevealedBoRPost.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCombinedPost', () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import moment from 'moment-timezone';
|
||||
|
||||
import {Post} from '@constants';
|
||||
import {isExpiredBoRPost} from '@utils/bor';
|
||||
import {toMilliseconds} from '@utils/datetime';
|
||||
import {isFromWebhook} from '@utils/post';
|
||||
import {ensureString, includes, isArrayOf, isStringArray, secureGetFromRecord} from '@utils/types';
|
||||
|
|
@ -203,6 +204,11 @@ export function selectOrderedPosts(
|
|||
for (let i = posts.length - 1; i >= 0; i--) {
|
||||
const post: PostWithPrevAndNext = {currentPost: posts[i]};
|
||||
post.isSaved = savedPostIds.has(post.currentPost.id);
|
||||
|
||||
if (!isThreadScreen && isExpiredBoRPost(post.currentPost)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (includePrevNext) {
|
||||
post.nextPost = posts[i - 1];
|
||||
if (!isThreadScreen || out[out.length - 1]?.type !== 'thread-overview') {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import {showOverlay} from '@screens/navigation';
|
||||
|
||||
import {showPlaybookErrorSnackbar} from '.';
|
||||
import {showBoRPostErrorSnackbar, showPlaybookErrorSnackbar} from '.';
|
||||
|
||||
describe('snack bar', () => {
|
||||
describe('showPlaybookErrorSnackbar', () => {
|
||||
|
|
@ -15,4 +15,23 @@ describe('snack bar', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('showBoRPostErrorSnackbar', () => {
|
||||
it('should show snackbar', () => {
|
||||
showBoRPostErrorSnackbar();
|
||||
|
||||
expect(showOverlay).toHaveBeenCalledWith('SnackBar', {
|
||||
barType: 'BOR_POST_EXPIRED',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show custom message when provided', () => {
|
||||
showBoRPostErrorSnackbar('custom message');
|
||||
|
||||
expect(showOverlay).toHaveBeenCalledWith('SnackBar', {
|
||||
barType: 'BOR_POST_EXPIRED',
|
||||
customMessage: 'custom message',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,3 +70,10 @@ export const showPlaybookErrorSnackbar = () => {
|
|||
barType: SNACK_BAR_TYPE.PLAYBOOK_ERROR,
|
||||
});
|
||||
};
|
||||
|
||||
export const showBoRPostErrorSnackbar = (message?: string) => {
|
||||
return showSnackBar({
|
||||
barType: SNACK_BAR_TYPE.BOR_POST_EXPIRED,
|
||||
customMessage: message,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -525,6 +525,7 @@
|
|||
"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.placeholder": "View message",
|
||||
"mobile.calls_audio_device": "Select audio device",
|
||||
"mobile.calls_bluetooth": "Bluetooth",
|
||||
"mobile.calls_call_ended": "Call ended",
|
||||
|
|
@ -1400,6 +1401,7 @@
|
|||
"snack.bar.agent.regenerate.error": "Failed to regenerate response",
|
||||
"snack.bar.agent.stop.error": "Failed to stop generation",
|
||||
"snack.bar.agent.tool.approval.error": "Failed to submit tool approval",
|
||||
"snack.bar.bor_post_expired.error": "This burn-on-read post has expired and can no longer be revealed.",
|
||||
"snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
|
||||
"snack.bar.code.copied": "Code copied to clipboard",
|
||||
"snack.bar.default": "Error",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,11 @@ class TestHelperSingleton {
|
|||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
await operator.handleRole({
|
||||
roles: Object.values(this.basicRoles!),
|
||||
prepareRecordsOnly: false,
|
||||
});
|
||||
|
||||
return {database, operator};
|
||||
};
|
||||
|
||||
|
|
@ -751,7 +756,7 @@ class TestHelperSingleton {
|
|||
};
|
||||
|
||||
fakePostModel = (overwrite?: Partial<PostModel>): PostModel => {
|
||||
return {
|
||||
const fakePost: PostModel = {
|
||||
...this.fakeModel(),
|
||||
channelId: this.generateId(),
|
||||
createAt: 0,
|
||||
|
|
@ -781,6 +786,9 @@ class TestHelperSingleton {
|
|||
toApi: jest.fn(),
|
||||
...overwrite,
|
||||
};
|
||||
|
||||
jest.mocked(fakePost.observe).mockReturnValue(of$(fakePost));
|
||||
return fakePost;
|
||||
};
|
||||
|
||||
fakeGroupModel = (overwrite?: Partial<GroupModel>): GroupModel => {
|
||||
|
|
@ -1439,6 +1447,8 @@ class TestHelperSingleton {
|
|||
description: 'authentication.roles.global_admin.description',
|
||||
permissions: [
|
||||
'system_admin_permission',
|
||||
'create_post',
|
||||
'edit_post',
|
||||
],
|
||||
scheme_managed: true,
|
||||
built_in: true,
|
||||
|
|
@ -1450,6 +1460,8 @@ class TestHelperSingleton {
|
|||
description: 'authentication.roles.global_user.description',
|
||||
permissions: [
|
||||
'system_user_permission',
|
||||
'create_post',
|
||||
'edit_post',
|
||||
],
|
||||
scheme_managed: true,
|
||||
built_in: true,
|
||||
|
|
@ -1461,6 +1473,8 @@ class TestHelperSingleton {
|
|||
description: 'authentication.roles.team_admin.description',
|
||||
permissions: [
|
||||
'team_admin_permission',
|
||||
'create_post',
|
||||
'edit_post',
|
||||
],
|
||||
scheme_managed: true,
|
||||
built_in: true,
|
||||
|
|
@ -1472,6 +1486,8 @@ class TestHelperSingleton {
|
|||
description: 'authentication.roles.team_user.description',
|
||||
permissions: [
|
||||
'team_user_permission',
|
||||
'create_post',
|
||||
'edit_post',
|
||||
],
|
||||
scheme_managed: true,
|
||||
built_in: true,
|
||||
|
|
@ -1483,6 +1499,8 @@ class TestHelperSingleton {
|
|||
description: 'authentication.roles.channel_admin.description',
|
||||
permissions: [
|
||||
'channel_admin_permission',
|
||||
'create_post',
|
||||
'edit_post',
|
||||
],
|
||||
scheme_managed: true,
|
||||
built_in: true,
|
||||
|
|
@ -1494,6 +1512,8 @@ class TestHelperSingleton {
|
|||
description: 'authentication.roles.channel_user.description',
|
||||
permissions: [
|
||||
'channel_user_permission',
|
||||
'create_post',
|
||||
'edit_post',
|
||||
],
|
||||
scheme_managed: true,
|
||||
built_in: true,
|
||||
|
|
|
|||
4
types/api/posts.d.ts
vendored
4
types/api/posts.d.ts
vendored
|
|
@ -30,7 +30,8 @@ type PostType =
|
|||
| 'custom_calls_recording'
|
||||
| 'custom_run_update'
|
||||
| 'custom_llmbot'
|
||||
| 'custom_llm_postback';
|
||||
| 'custom_llm_postback'
|
||||
| 'burn_on_read';
|
||||
|
||||
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph' | 'permalink';
|
||||
|
||||
|
|
@ -76,6 +77,7 @@ type PostMetadata = {
|
|||
images?: Dictionary<PostImage | undefined>;
|
||||
reactions?: Reaction[];
|
||||
priority?: PostPriority;
|
||||
expire_at?: number;
|
||||
};
|
||||
|
||||
type Post = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue