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:
Harshil Sharma 2025-12-11 05:12:16 +01:00 committed by GitHub
parent ebb7a2241f
commit 2fb2e9d899
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1817 additions and 37 deletions

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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') {

View file

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

View file

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

View file

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

View file

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

View file

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