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 Database from '@nozbe/watermelondb/Database';
|
||||||
|
|
||||||
|
import {getPosts} from '@actions/local/post';
|
||||||
import {ActionType} from '@constants';
|
import {ActionType} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
|
import {PostTypes} from '@constants/post';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
storeConfig,
|
storeConfig,
|
||||||
|
|
@ -17,6 +20,7 @@ import {
|
||||||
setLastServerVersionCheck,
|
setLastServerVersionCheck,
|
||||||
setGlobalThreadsTab,
|
setGlobalThreadsTab,
|
||||||
dismissAnnouncement,
|
dismissAnnouncement,
|
||||||
|
expiredBoRPostCleanup,
|
||||||
} from './systems';
|
} from './systems';
|
||||||
|
|
||||||
import type {DataRetentionPoliciesRequest} from '@actions/remote/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 {Events} from '@constants';
|
||||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
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 DatabaseManager from '@database/manager';
|
||||||
import {getServerCredentials} from '@init/credentials';
|
import {getServerCredentials} from '@init/credentials';
|
||||||
import {queryAllChannelsForTeam} from '@queries/servers/channel';
|
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 PostModel from '@typings/database/models/servers/post';
|
||||||
|
import {isExpiredBoRPost} from '@utils/bor';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
import {deletePosts} from './post';
|
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) {
|
if (postIds.length) {
|
||||||
const batchSize = 1000;
|
const batchSize = 1000;
|
||||||
const deletePromises = [];
|
const deletePromises = [];
|
||||||
|
|
@ -314,3 +325,55 @@ export async function dismissAnnouncement(serverUrl: string, announcementText: s
|
||||||
return {error};
|
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,
|
editPost,
|
||||||
acknowledgePost,
|
acknowledgePost,
|
||||||
unacknowledgePost,
|
unacknowledgePost,
|
||||||
|
revealBoRPost,
|
||||||
fetchPostsForChannel,
|
fetchPostsForChannel,
|
||||||
fetchPostsForUnreadChannels,
|
fetchPostsForUnreadChannels,
|
||||||
fetchPosts,
|
fetchPosts,
|
||||||
|
|
@ -100,6 +101,17 @@ const mockClient = {
|
||||||
patchPost: jest.fn((message: string, postId: string) => ({...post1, id: postId, message})),
|
patchPost: jest.fn((message: string, postId: string) => ({...post1, id: postId, message})),
|
||||||
acknowledgePost: jest.fn(() => ({acknowledged_at: acknowledgedTime})),
|
acknowledgePost: jest.fn(() => ({acknowledged_at: acknowledgedTime})),
|
||||||
unacknowledgePost: jest.fn(),
|
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,
|
getPosts: genericGetPostsMock,
|
||||||
getPostsBefore: 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]})),
|
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);
|
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) {
|
export async function fetchSavedPosts(serverUrl: string, teamId?: string, channelId?: string, page?: number, perPage?: number) {
|
||||||
try {
|
try {
|
||||||
const client = NetworkManager.getClient(serverUrl);
|
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 {handleAgentPostUpdate} from '@agents/actions/websocket';
|
||||||
|
|
||||||
import * as bookmark from '@actions/local/channel_bookmark';
|
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 scheduledPost from '@actions/websocket/scheduled_post';
|
||||||
import * as calls from '@calls/connection/websocket_event_handlers';
|
import * as calls from '@calls/connection/websocket_event_handlers';
|
||||||
import {WebsocketEvents} from '@constants';
|
import {WebsocketEvents} from '@constants';
|
||||||
|
|
@ -308,6 +309,11 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
|
||||||
case WebsocketEvents.AGENTS_POST_UPDATE:
|
case WebsocketEvents.AGENTS_POST_UPDATE:
|
||||||
handleAgentPostUpdate(msg);
|
handleAgentPostUpdate(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// Burn on Read Events
|
||||||
|
case WebsocketEvents.BOR_POST_REVEALED:
|
||||||
|
handleBoRPostRevealedEvent(serverUrl, msg);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
handlePlaybookEvents(serverUrl, msg);
|
handlePlaybookEvents(serverUrl, msg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import {DeviceEventEmitter} from 'react-native';
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {markChannelAsViewed} from '@actions/local/channel';
|
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 {markChannelAsRead} from '@actions/remote/channel';
|
||||||
import {entry, handleEntryAfterLoadNavigation} from '@actions/remote/entry/common';
|
import {entry, handleEntryAfterLoadNavigation} from '@actions/remote/entry/common';
|
||||||
import {deferredAppEntryActions} from '@actions/remote/entry/deferred';
|
import {deferredAppEntryActions} from '@actions/remote/entry/deferred';
|
||||||
|
|
@ -160,6 +160,7 @@ describe('WebSocket Index Actions', () => {
|
||||||
expect(deferredAppEntryActions).toHaveBeenCalled();
|
expect(deferredAppEntryActions).toHaveBeenCalled();
|
||||||
expect(openAllUnreadChannels).toHaveBeenCalled();
|
expect(openAllUnreadChannels).toHaveBeenCalled();
|
||||||
expect(dataRetentionCleanup).toHaveBeenCalled();
|
expect(dataRetentionCleanup).toHaveBeenCalled();
|
||||||
|
expect(expiredBoRPostCleanup).toHaveBeenCalled();
|
||||||
expect(AppsManager.refreshAppBindings).toHaveBeenCalled();
|
expect(AppsManager.refreshAppBindings).toHaveBeenCalled();
|
||||||
expect(handlePlaybookReconnect).toHaveBeenCalledWith(serverUrl);
|
expect(handlePlaybookReconnect).toHaveBeenCalledWith(serverUrl);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {markChannelAsViewed} from '@actions/local/channel';
|
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 {markChannelAsRead} from '@actions/remote/channel';
|
||||||
import {
|
import {
|
||||||
entry,
|
entry,
|
||||||
|
|
@ -104,6 +104,8 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel
|
||||||
|
|
||||||
dataRetentionCleanup(serverUrl);
|
dataRetentionCleanup(serverUrl);
|
||||||
|
|
||||||
|
expiredBoRPostCleanup(serverUrl);
|
||||||
|
|
||||||
AppsManager.refreshAppBindings(serverUrl, groupLabel);
|
AppsManager.refreshAppBindings(serverUrl, groupLabel);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -308,4 +308,13 @@ describe('ClientPosts', () => {
|
||||||
{method: 'post'},
|
{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>;
|
acknowledgePost: (postId: string, userId: string) => Promise<PostAcknowledgement>;
|
||||||
unacknowledgePost: (postId: string, userId: string) => Promise<any>;
|
unacknowledgePost: (postId: string, userId: string) => Promise<any>;
|
||||||
sendTestNotification: () => Promise<{status: 'OK'}>;
|
sendTestNotification: () => Promise<{status: 'OK'}>;
|
||||||
|
revealBoRPost: (postId: string) => Promise<Post>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientPosts = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
|
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'},
|
{method: 'post'},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
revealBoRPost = async (postId: string) => {
|
||||||
|
return this.doFetch(
|
||||||
|
`${this.getPostRoute(postId)}/reveal`,
|
||||||
|
{method: 'get'},
|
||||||
|
);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ClientPosts;
|
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.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React from 'react';
|
import React, {useCallback, useMemo} from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
|
import {removePost} from '@actions/local/post';
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
import FormattedTime from '@components/formatted_time';
|
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 PostPriorityLabel from '@components/post_priority/post_priority_label';
|
||||||
import {CHANNEL, THREAD} from '@constants/screens';
|
import {CHANNEL, THREAD} from '@constants/screens';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {DEFAULT_LOCALE} from '@i18n';
|
import {DEFAULT_LOCALE} from '@i18n';
|
||||||
|
import {isOwnBoRPost, isUnrevealedBoRPost} from '@utils/bor';
|
||||||
import {postUserDisplayName} from '@utils/post';
|
import {postUserDisplayName} from '@utils/post';
|
||||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {ensureString} from '@utils/types';
|
import {ensureString} from '@utils/types';
|
||||||
|
|
@ -96,6 +101,16 @@ const Header = (props: HeaderProps) => {
|
||||||
const userIconOverride = ensureString(post.props?.override_icon_url);
|
const userIconOverride = ensureString(post.props?.override_icon_url);
|
||||||
const usernameOverride = ensureString(post.props?.override_username);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={[style.container, pendingPostStyle]}>
|
<View style={[style.container, pendingPostStyle]}>
|
||||||
|
|
@ -141,6 +156,20 @@ const Header = (props: HeaderProps) => {
|
||||||
label={post.metadata.priority.priority}
|
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 &&
|
{!isCRTEnabled && showReply && commentCount > 0 &&
|
||||||
<HeaderReply
|
<HeaderReply
|
||||||
commentCount={commentCount}
|
commentCount={commentCount}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {queryReactionsForPost} from '@queries/servers/reaction';
|
||||||
import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role';
|
import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role';
|
||||||
import {observeThreadById} from '@queries/servers/thread';
|
import {observeThreadById} from '@queries/servers/thread';
|
||||||
import {observeCurrentUser} from '@queries/servers/user';
|
import {observeCurrentUser} from '@queries/servers/user';
|
||||||
|
import {isBoRPost} from '@utils/bor';
|
||||||
import {areConsecutivePosts, isPostEphemeral} from '@utils/post';
|
import {areConsecutivePosts, isPostEphemeral} from '@utils/post';
|
||||||
|
|
||||||
import Post from './post';
|
import Post from './post';
|
||||||
|
|
@ -124,7 +125,9 @@ const withPost = withObservables(
|
||||||
|
|
||||||
const hasReplies = observeHasReplies(database, post);//Need to review and understand
|
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)))),
|
switchMap((user) => of$(Boolean(post && previousPost && !user?.isBot && areConsecutivePosts(post, previousPost)))),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
|
|
||||||
import React, {type ComponentProps} from 'react';
|
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 NetworkManager from '@managers/network_manager';
|
||||||
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import {getPostById} from '@queries/servers/post';
|
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';
|
import type PostModel from '@typings/database/models/servers/post';
|
||||||
|
|
||||||
jest.mock('@managers/performance_metrics_manager');
|
jest.mock('@managers/performance_metrics_manager');
|
||||||
|
jest.mock('@components/post_list/post/burn_on_read/unrevealed');
|
||||||
|
|
||||||
describe('performance metrics', () => {
|
describe('performance metrics', () => {
|
||||||
let database: Database;
|
let database: Database;
|
||||||
|
|
@ -78,4 +81,21 @@ describe('performance metrics', () => {
|
||||||
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl);
|
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 {fetchAndSwitchToThread} from '@actions/remote/thread';
|
||||||
import CallsCustomMessage from '@calls/components/calls_custom_message';
|
import CallsCustomMessage from '@calls/components/calls_custom_message';
|
||||||
import {isCallsCustomMessage} from '@calls/utils';
|
import {isCallsCustomMessage} from '@calls/utils';
|
||||||
|
import UnrevealedBurnOnReadPost from '@components/post_list/post/burn_on_read/unrevealed';
|
||||||
import SystemAvatar from '@components/system_avatar';
|
import SystemAvatar from '@components/system_avatar';
|
||||||
import SystemHeader from '@components/system_header';
|
import SystemHeader from '@components/system_header';
|
||||||
import {POST_TIME_TO_FAIL} from '@constants/post';
|
import {POST_TIME_TO_FAIL} from '@constants/post';
|
||||||
|
|
@ -22,6 +23,7 @@ import {useTheme} from '@context/theme';
|
||||||
import {useIsTablet} from '@hooks/device';
|
import {useIsTablet} from '@hooks/device';
|
||||||
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import {openAsBottomSheet} from '@screens/navigation';
|
import {openAsBottomSheet} from '@screens/navigation';
|
||||||
|
import {isBoRPost, isUnrevealedBoRPost} from '@utils/bor';
|
||||||
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
|
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
|
||||||
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
|
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
@ -159,6 +161,8 @@ const Post = ({
|
||||||
const isFailed = isPostFailed(post);
|
const isFailed = isPostFailed(post);
|
||||||
const isSystemPost = isSystemMessage(post);
|
const isSystemPost = isSystemMessage(post);
|
||||||
const isCallsPost = isCallsCustomMessage(post);
|
const isCallsPost = isCallsCustomMessage(post);
|
||||||
|
const borPost = isBoRPost(post);
|
||||||
|
const isUnrevealedPost = isUnrevealedBoRPost(post);
|
||||||
const isAgentPostType = isAgentPost(post);
|
const isAgentPostType = isAgentPost(post);
|
||||||
const hasBeenDeleted = (post.deleteAt !== 0);
|
const hasBeenDeleted = (post.deleteAt !== 0);
|
||||||
const isWebHook = isFromWebhook(post);
|
const isWebHook = isFromWebhook(post);
|
||||||
|
|
@ -190,7 +194,8 @@ const Post = ({
|
||||||
if (isEphemeral || hasBeenDeleted) {
|
if (isEphemeral || hasBeenDeleted) {
|
||||||
removePost(serverUrl, post);
|
removePost(serverUrl, post);
|
||||||
} else if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) {
|
} 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;
|
const postRootId = post.rootId || post.id;
|
||||||
fetchAndSwitchToThread(serverUrl, postRootId);
|
fetchAndSwitchToThread(serverUrl, postRootId);
|
||||||
}
|
}
|
||||||
|
|
@ -199,10 +204,7 @@ const Post = ({
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
pressDetected.current = false;
|
pressDetected.current = false;
|
||||||
}, 300);
|
}, 300);
|
||||||
}, [
|
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost]);
|
||||||
hasBeenDeleted, isAutoResponder, isEphemeral,
|
|
||||||
isPendingOrFailed, isSystemPost, location, serverUrl, post,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handlePress = useHideExtraKeyboardIfNeeded(() => {
|
const handlePress = useHideExtraKeyboardIfNeeded(() => {
|
||||||
pressDetected.current = true;
|
pressDetected.current = true;
|
||||||
|
|
@ -363,6 +365,10 @@ const Post = ({
|
||||||
joiningChannelId={null}
|
joiningChannelId={null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (isUnrevealedPost) {
|
||||||
|
body = (
|
||||||
|
<UnrevealedBurnOnReadPost post={post}/>
|
||||||
|
);
|
||||||
} else if (isAgentPostType && !hasBeenDeleted) {
|
} else if (isAgentPostType && !hasBeenDeleted) {
|
||||||
body = (
|
body = (
|
||||||
<AgentPost
|
<AgentPost
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ describe('ScheduledPostIndicator', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
|
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', () => {
|
test('should render thread message when isThread is true', () => {
|
||||||
|
|
@ -63,7 +63,7 @@ describe('ScheduledPostIndicator', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
|
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', () => {
|
test('should render correct message when there is only one scheduled post', () => {
|
||||||
|
|
@ -75,7 +75,7 @@ describe('ScheduledPostIndicator', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
renderWithIntlAndTheme(<ScheduledPostIndicator {...props}/>);
|
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 () => {
|
test('should handle see all scheduled posts click', async () => {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export const SYSTEM_IDENTIFIERS = {
|
||||||
TEAM_HISTORY: 'teamHistory',
|
TEAM_HISTORY: 'teamHistory',
|
||||||
WEBSOCKET: 'WebSocket',
|
WEBSOCKET: 'WebSocket',
|
||||||
PLAYBOOKS_VERSION: 'playbooks_version',
|
PLAYBOOKS_VERSION: 'playbooks_version',
|
||||||
|
LAST_BOR_POST_CLEANUP_RUN: 'lastBoRPostCleanupRun',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GLOBAL_IDENTIFIERS = {
|
export const GLOBAL_IDENTIFIERS = {
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,11 @@ export const PostTypes = {
|
||||||
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
|
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
|
||||||
CUSTOM_CALLS: 'custom_calls',
|
CUSTOM_CALLS: 'custom_calls',
|
||||||
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
|
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
|
||||||
|
|
||||||
CUSTOM_LLMBOT: 'custom_llmbot',
|
CUSTOM_LLMBOT: 'custom_llmbot',
|
||||||
CUSTOM_LLM_POSTBACK: 'custom_llm_postback',
|
CUSTOM_LLM_POSTBACK: 'custom_llm_postback',
|
||||||
|
|
||||||
|
BURN_ON_READ: 'burn_on_read',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PostPriorityColors = {
|
export const PostPriorityColors = {
|
||||||
|
|
@ -52,6 +55,8 @@ export enum PostPriorityType {
|
||||||
|
|
||||||
export const POST_TIME_TO_FAIL = toMilliseconds({seconds: 10});
|
export const POST_TIME_TO_FAIL = toMilliseconds({seconds: 10});
|
||||||
|
|
||||||
|
export const BOR_POST_CLEANUP_MIN_RUN_INTERVAL = toMilliseconds({minutes: 15});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
POST_COLLAPSE_TIMEOUT: toMilliseconds({minutes: 5}),
|
POST_COLLAPSE_TIMEOUT: toMilliseconds({minutes: 5}),
|
||||||
POST_TYPES: PostTypes,
|
POST_TYPES: PostTypes,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export const SNACK_BAR_TYPE = keyMirror({
|
||||||
RESCHEDULED_POST: null,
|
RESCHEDULED_POST: null,
|
||||||
DELETE_SCHEDULED_POST_ERROR: null,
|
DELETE_SCHEDULED_POST_ERROR: null,
|
||||||
PLAYBOOK_ERROR: null,
|
PLAYBOOK_ERROR: null,
|
||||||
|
BOR_POST_EXPIRED: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MESSAGE_TYPE = {
|
export const MESSAGE_TYPE = {
|
||||||
|
|
@ -117,6 +118,10 @@ const messages = defineMessages({
|
||||||
id: 'snack.bar.playbook.error',
|
id: 'snack.bar.playbook.error',
|
||||||
defaultMessage: 'Unable to perform action. Please try again later.',
|
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> = {
|
export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
|
||||||
|
|
@ -217,6 +222,12 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
|
||||||
canUndo: false,
|
canUndo: false,
|
||||||
type: MESSAGE_TYPE.ERROR,
|
type: MESSAGE_TYPE.ERROR,
|
||||||
},
|
},
|
||||||
|
BOR_POST_EXPIRED: {
|
||||||
|
message: messages.BOR_POST_EXPIRED,
|
||||||
|
iconName: 'alert-outline',
|
||||||
|
canUndo: false,
|
||||||
|
type: MESSAGE_TYPE.ERROR,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,9 @@ const WebsocketEvents = {
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
|
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
|
||||||
|
|
||||||
|
// Burn on Read
|
||||||
|
BOR_POST_REVEALED: 'post_revealed',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WebsocketEvents;
|
export default WebsocketEvents;
|
||||||
|
|
|
||||||
|
|
@ -752,6 +752,67 @@ describe('*** Operator: Post Handlers tests ***', () => {
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0].id).toBe(uploadedFiles[0].id);
|
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 ***', () => {
|
describe('*** Operator: merge chunks ***', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {Q} from '@nozbe/watermelondb';
|
||||||
|
|
||||||
import {ActionType} from '@constants';
|
import {ActionType} from '@constants';
|
||||||
import {MM_TABLES} from '@constants/database';
|
import {MM_TABLES} from '@constants/database';
|
||||||
|
import {PostTypes} from '@constants/post';
|
||||||
import {buildDraftKey} from '@database/operator/server_data_operator/comparators';
|
import {buildDraftKey} from '@database/operator/server_data_operator/comparators';
|
||||||
import {
|
import {
|
||||||
transformDraftRecord,
|
transformDraftRecord,
|
||||||
|
|
@ -19,6 +20,7 @@ import {queryScheduledPostsForTeam} from '@queries/servers/scheduled_post';
|
||||||
import {getCurrentTeamId} from '@queries/servers/system';
|
import {getCurrentTeamId} from '@queries/servers/system';
|
||||||
import FileModel from '@typings/database/models/servers/file';
|
import FileModel from '@typings/database/models/servers/file';
|
||||||
import ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
|
import ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
|
||||||
|
import {isUnrevealedBoRPost} from '@utils/bor';
|
||||||
import {safeParseJSON} from '@utils/helpers';
|
import {safeParseJSON} from '@utils/helpers';
|
||||||
import {logWarning} from '@utils/log';
|
import {logWarning} from '@utils/log';
|
||||||
|
|
||||||
|
|
@ -364,7 +366,14 @@ const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
|
||||||
deleteRawValues: pendingPostsToDelete,
|
deleteRawValues: pendingPostsToDelete,
|
||||||
tableName,
|
tableName,
|
||||||
fieldName: 'id',
|
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({
|
const preparedPosts = (await this.prepareRecords({
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {License} from '@constants';
|
||||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
import DatabaseManager from '@database/manager';
|
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 ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
import type {Database} from '@nozbe/watermelondb';
|
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', () => {
|
describe('observeIsMinimumLicenseTier', () => {
|
||||||
const serverUrl = 'baseHandler.test.com';
|
const serverUrl = 'baseHandler.test.com';
|
||||||
let database: Database;
|
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) => {
|
export const getGlobalDataRetentionPolicy = async (database: Database) => {
|
||||||
try {
|
try {
|
||||||
const data = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.DATA_RETENTION_POLICIES);
|
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 {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
|
||||||
import {translateYConfig} from '@hooks/gallery';
|
import {translateYConfig} from '@hooks/gallery';
|
||||||
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
import {useLightboxSharedValues} from '@screens/gallery/lightbox_swipeout/context';
|
||||||
|
import {formatTime} from '@utils/datetime';
|
||||||
import {typography} from '@utils/typography';
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
import {useStateFromSharedValue} from '../hooks';
|
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> = ({
|
const BottomControls: React.FC<BottomControlsProps> = ({
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
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 {observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system';
|
||||||
import {observeIsCRTEnabled, observeThreadById} from '@queries/servers/thread';
|
import {observeIsCRTEnabled, observeThreadById} from '@queries/servers/thread';
|
||||||
import {observeCurrentUser} from '@queries/servers/user';
|
import {observeCurrentUser} from '@queries/servers/user';
|
||||||
|
import {isBoRPost, isUnrevealedBoRPost} from '@utils/bor';
|
||||||
import {toMilliseconds} from '@utils/datetime';
|
import {toMilliseconds} from '@utils/datetime';
|
||||||
import {isMinimumServerVersion} from '@utils/helpers';
|
import {isMinimumServerVersion} from '@utils/helpers';
|
||||||
import {isFromWebhook, isSystemMessage} from '@utils/post';
|
import {isFromWebhook, isSystemMessage} from '@utils/post';
|
||||||
|
|
@ -83,6 +84,8 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
||||||
const serverVersion = observeConfigValue(database, 'Version');
|
const serverVersion = observeConfigValue(database, 'Version');
|
||||||
const postEditTimeLimit = observeConfigIntValue(database, 'PostEditTimeLimit', -1);
|
const postEditTimeLimit = observeConfigIntValue(database, 'PostEditTimeLimit', -1);
|
||||||
const bindings = AppsManager.observeBindings(serverUrl, AppBindingLocations.POST_MENU_ITEM);
|
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 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)));
|
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);
|
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);
|
return of$(!isSystemMessage(post) && !isArchived && !isReadOnly);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const isSaved = observePostSaved(database, post.id);
|
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]) => {
|
switchMap(([lt, ls, c, u, isArchived, isReadOnly, until, canPost]) => {
|
||||||
const isOwner = u?.id === post.userId;
|
const isOwner = u?.id === post.userId;
|
||||||
const canEditPostPermission = (c && u) ? observeCanEditPost(database, isOwner, post, lt, ls, c, u) : of$(false);
|
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]) => {
|
switchMap(([permission, readOnly, maxAllowed, isArchived]) => {
|
||||||
return of$(!isSystemMessage(post) && permission && !readOnly && !isArchived && maxAllowed && showAddReaction);
|
return of$(!isSystemMessage(post) && permission && !readOnly && !isArchived && maxAllowed && showAddReaction);
|
||||||
}),
|
}),
|
||||||
|
|
@ -163,6 +166,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour
|
||||||
post,
|
post,
|
||||||
thread,
|
thread,
|
||||||
bindings,
|
bindings,
|
||||||
|
isBoRPost: of$(borPost),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,14 @@ type PostOptionsProps = {
|
||||||
componentId: AvailableScreens;
|
componentId: AvailableScreens;
|
||||||
bindings: AppBinding[];
|
bindings: AppBinding[];
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
|
isBoRPost?: boolean;
|
||||||
};
|
};
|
||||||
const PostOptions = ({
|
const PostOptions = ({
|
||||||
canAddReaction, canDelete, canEdit,
|
canAddReaction, canDelete, canEdit,
|
||||||
canMarkAsUnread, canPin, canReply,
|
canMarkAsUnread, canPin, canReply,
|
||||||
combinedPost, componentId, isSaved,
|
combinedPost, componentId, isSaved,
|
||||||
sourceScreen, post, thread, bindings, serverUrl,
|
sourceScreen, post, thread, bindings, serverUrl,
|
||||||
|
isBoRPost,
|
||||||
}: PostOptionsProps) => {
|
}: PostOptionsProps) => {
|
||||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||||
const isTablet = useIsTablet();
|
const isTablet = useIsTablet();
|
||||||
|
|
@ -68,7 +70,7 @@ const PostOptions = ({
|
||||||
const isSystemPost = isSystemMessage(post);
|
const isSystemPost = isSystemMessage(post);
|
||||||
|
|
||||||
const canCopyPermalink = !isSystemPost && managedConfig?.copyAndPasteProtection !== 'true';
|
const canCopyPermalink = !isSystemPost && managedConfig?.copyAndPasteProtection !== 'true';
|
||||||
const canCopyText = canCopyPermalink && post.message;
|
const canCopyText = canCopyPermalink && post.message && !isBoRPost;
|
||||||
|
|
||||||
const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread);
|
const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread);
|
||||||
const shouldShowBindings = bindings.length > 0 && !isSystemPost;
|
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.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// 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', () => {
|
describe('Datetime', () => {
|
||||||
test('isSameDate (isSameMonth / isSameYear)', () => {
|
test('isSameDate (isSameMonth / isSameYear)', () => {
|
||||||
|
|
@ -85,3 +85,43 @@ describe('getReadableTimestamp', () => {
|
||||||
expect(deResult).toBe('15. Juni, 8:00 AM');
|
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);
|
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.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Post} from '@constants';
|
import {Post} from '@constants';
|
||||||
|
import {PostTypes} from '@constants/post';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
selectOrderedPostsWithPrevAndNext,
|
selectOrderedPostsWithPrevAndNext,
|
||||||
|
|
@ -13,6 +15,7 @@ import {
|
||||||
shouldFilterJoinLeavePost,
|
shouldFilterJoinLeavePost,
|
||||||
} from '.';
|
} from '.';
|
||||||
|
|
||||||
|
import type {PostListItem} from '@typings/components/post_list';
|
||||||
import type PostModel from '@typings/database/models/servers/post';
|
import type PostModel from '@typings/database/models/servers/post';
|
||||||
|
|
||||||
const mockPostModel = (overrides: Partial<PostModel> = {}): PostModel => ({
|
const mockPostModel = (overrides: Partial<PostModel> = {}): PostModel => ({
|
||||||
|
|
@ -267,6 +270,39 @@ describe('selectOrderedPosts', () => {
|
||||||
// The second post should not be marked as saved
|
// The second post should not be marked as saved
|
||||||
expect(typeof result[1].value === 'object' && result[1].value.isSaved).toBe(false);
|
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', () => {
|
describe('generateCombinedPost', () => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
|
|
||||||
import {Post} from '@constants';
|
import {Post} from '@constants';
|
||||||
|
import {isExpiredBoRPost} from '@utils/bor';
|
||||||
import {toMilliseconds} from '@utils/datetime';
|
import {toMilliseconds} from '@utils/datetime';
|
||||||
import {isFromWebhook} from '@utils/post';
|
import {isFromWebhook} from '@utils/post';
|
||||||
import {ensureString, includes, isArrayOf, isStringArray, secureGetFromRecord} from '@utils/types';
|
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--) {
|
for (let i = posts.length - 1; i >= 0; i--) {
|
||||||
const post: PostWithPrevAndNext = {currentPost: posts[i]};
|
const post: PostWithPrevAndNext = {currentPost: posts[i]};
|
||||||
post.isSaved = savedPostIds.has(post.currentPost.id);
|
post.isSaved = savedPostIds.has(post.currentPost.id);
|
||||||
|
|
||||||
|
if (!isThreadScreen && isExpiredBoRPost(post.currentPost)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (includePrevNext) {
|
if (includePrevNext) {
|
||||||
post.nextPost = posts[i - 1];
|
post.nextPost = posts[i - 1];
|
||||||
if (!isThreadScreen || out[out.length - 1]?.type !== 'thread-overview') {
|
if (!isThreadScreen || out[out.length - 1]?.type !== 'thread-overview') {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import {showOverlay} from '@screens/navigation';
|
import {showOverlay} from '@screens/navigation';
|
||||||
|
|
||||||
import {showPlaybookErrorSnackbar} from '.';
|
import {showBoRPostErrorSnackbar, showPlaybookErrorSnackbar} from '.';
|
||||||
|
|
||||||
describe('snack bar', () => {
|
describe('snack bar', () => {
|
||||||
describe('showPlaybookErrorSnackbar', () => {
|
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,
|
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_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.android.photos_permission_denied_title": "{applicationName} would like to access your photos",
|
||||||
"mobile.announcement_banner.title": "Announcement",
|
"mobile.announcement_banner.title": "Announcement",
|
||||||
|
"mobile.burn_on_read.placeholder": "View message",
|
||||||
"mobile.calls_audio_device": "Select audio device",
|
"mobile.calls_audio_device": "Select audio device",
|
||||||
"mobile.calls_bluetooth": "Bluetooth",
|
"mobile.calls_bluetooth": "Bluetooth",
|
||||||
"mobile.calls_call_ended": "Call ended",
|
"mobile.calls_call_ended": "Call ended",
|
||||||
|
|
@ -1400,6 +1401,7 @@
|
||||||
"snack.bar.agent.regenerate.error": "Failed to regenerate response",
|
"snack.bar.agent.regenerate.error": "Failed to regenerate response",
|
||||||
"snack.bar.agent.stop.error": "Failed to stop generation",
|
"snack.bar.agent.stop.error": "Failed to stop generation",
|
||||||
"snack.bar.agent.tool.approval.error": "Failed to submit tool approval",
|
"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.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
|
||||||
"snack.bar.code.copied": "Code copied to clipboard",
|
"snack.bar.code.copied": "Code copied to clipboard",
|
||||||
"snack.bar.default": "Error",
|
"snack.bar.default": "Error",
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,11 @@ class TestHelperSingleton {
|
||||||
prepareRecordsOnly: false,
|
prepareRecordsOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await operator.handleRole({
|
||||||
|
roles: Object.values(this.basicRoles!),
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
return {database, operator};
|
return {database, operator};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -751,7 +756,7 @@ class TestHelperSingleton {
|
||||||
};
|
};
|
||||||
|
|
||||||
fakePostModel = (overwrite?: Partial<PostModel>): PostModel => {
|
fakePostModel = (overwrite?: Partial<PostModel>): PostModel => {
|
||||||
return {
|
const fakePost: PostModel = {
|
||||||
...this.fakeModel(),
|
...this.fakeModel(),
|
||||||
channelId: this.generateId(),
|
channelId: this.generateId(),
|
||||||
createAt: 0,
|
createAt: 0,
|
||||||
|
|
@ -781,6 +786,9 @@ class TestHelperSingleton {
|
||||||
toApi: jest.fn(),
|
toApi: jest.fn(),
|
||||||
...overwrite,
|
...overwrite,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
jest.mocked(fakePost.observe).mockReturnValue(of$(fakePost));
|
||||||
|
return fakePost;
|
||||||
};
|
};
|
||||||
|
|
||||||
fakeGroupModel = (overwrite?: Partial<GroupModel>): GroupModel => {
|
fakeGroupModel = (overwrite?: Partial<GroupModel>): GroupModel => {
|
||||||
|
|
@ -1439,6 +1447,8 @@ class TestHelperSingleton {
|
||||||
description: 'authentication.roles.global_admin.description',
|
description: 'authentication.roles.global_admin.description',
|
||||||
permissions: [
|
permissions: [
|
||||||
'system_admin_permission',
|
'system_admin_permission',
|
||||||
|
'create_post',
|
||||||
|
'edit_post',
|
||||||
],
|
],
|
||||||
scheme_managed: true,
|
scheme_managed: true,
|
||||||
built_in: true,
|
built_in: true,
|
||||||
|
|
@ -1450,6 +1460,8 @@ class TestHelperSingleton {
|
||||||
description: 'authentication.roles.global_user.description',
|
description: 'authentication.roles.global_user.description',
|
||||||
permissions: [
|
permissions: [
|
||||||
'system_user_permission',
|
'system_user_permission',
|
||||||
|
'create_post',
|
||||||
|
'edit_post',
|
||||||
],
|
],
|
||||||
scheme_managed: true,
|
scheme_managed: true,
|
||||||
built_in: true,
|
built_in: true,
|
||||||
|
|
@ -1461,6 +1473,8 @@ class TestHelperSingleton {
|
||||||
description: 'authentication.roles.team_admin.description',
|
description: 'authentication.roles.team_admin.description',
|
||||||
permissions: [
|
permissions: [
|
||||||
'team_admin_permission',
|
'team_admin_permission',
|
||||||
|
'create_post',
|
||||||
|
'edit_post',
|
||||||
],
|
],
|
||||||
scheme_managed: true,
|
scheme_managed: true,
|
||||||
built_in: true,
|
built_in: true,
|
||||||
|
|
@ -1472,6 +1486,8 @@ class TestHelperSingleton {
|
||||||
description: 'authentication.roles.team_user.description',
|
description: 'authentication.roles.team_user.description',
|
||||||
permissions: [
|
permissions: [
|
||||||
'team_user_permission',
|
'team_user_permission',
|
||||||
|
'create_post',
|
||||||
|
'edit_post',
|
||||||
],
|
],
|
||||||
scheme_managed: true,
|
scheme_managed: true,
|
||||||
built_in: true,
|
built_in: true,
|
||||||
|
|
@ -1483,6 +1499,8 @@ class TestHelperSingleton {
|
||||||
description: 'authentication.roles.channel_admin.description',
|
description: 'authentication.roles.channel_admin.description',
|
||||||
permissions: [
|
permissions: [
|
||||||
'channel_admin_permission',
|
'channel_admin_permission',
|
||||||
|
'create_post',
|
||||||
|
'edit_post',
|
||||||
],
|
],
|
||||||
scheme_managed: true,
|
scheme_managed: true,
|
||||||
built_in: true,
|
built_in: true,
|
||||||
|
|
@ -1494,6 +1512,8 @@ class TestHelperSingleton {
|
||||||
description: 'authentication.roles.channel_user.description',
|
description: 'authentication.roles.channel_user.description',
|
||||||
permissions: [
|
permissions: [
|
||||||
'channel_user_permission',
|
'channel_user_permission',
|
||||||
|
'create_post',
|
||||||
|
'edit_post',
|
||||||
],
|
],
|
||||||
scheme_managed: true,
|
scheme_managed: true,
|
||||||
built_in: 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_calls_recording'
|
||||||
| 'custom_run_update'
|
| 'custom_run_update'
|
||||||
| 'custom_llmbot'
|
| 'custom_llmbot'
|
||||||
| 'custom_llm_postback';
|
| 'custom_llm_postback'
|
||||||
|
| 'burn_on_read';
|
||||||
|
|
||||||
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph' | 'permalink';
|
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph' | 'permalink';
|
||||||
|
|
||||||
|
|
@ -76,6 +77,7 @@ type PostMetadata = {
|
||||||
images?: Dictionary<PostImage | undefined>;
|
images?: Dictionary<PostImage | undefined>;
|
||||||
reactions?: Reaction[];
|
reactions?: Reaction[];
|
||||||
priority?: PostPriority;
|
priority?: PostPriority;
|
||||||
|
expire_at?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Post = {
|
type Post = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue