diff --git a/app/actions/local/systems.test.ts b/app/actions/local/systems.test.ts index a1a8a9b67..c97c86dab 100644 --- a/app/actions/local/systems.test.ts +++ b/app/actions/local/systems.test.ts @@ -3,10 +3,13 @@ import Database from '@nozbe/watermelondb/Database'; +import {getPosts} from '@actions/local/post'; import {ActionType} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import {PostTypes} from '@constants/post'; import DatabaseManager from '@database/manager'; import TestHelper from '@test/test_helper'; +import {logError} from '@utils/log'; import { storeConfig, @@ -17,6 +20,7 @@ import { setLastServerVersionCheck, setGlobalThreadsTab, dismissAnnouncement, + expiredBoRPostCleanup, } from './systems'; import type {DataRetentionPoliciesRequest} from '@actions/remote/systems'; @@ -251,3 +255,269 @@ describe('dismissAnnouncement', () => { }); }); +describe('expiredBoRPostCleanup', () => { + it('should delete expired BoR posts', async () => { + const database = operator.database; + jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve()); + + const channel: Channel = TestHelper.fakeChannel({ + id: 'channelid1', + team_id: 'teamid1', + }); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + const now = Date.now(); + + const borPostExpiredForAll = TestHelper.fakePost({ + id: 'postid1', + channel_id: channel.id, + type: PostTypes.BURN_ON_READ, + props: {expire_at: now - 10000}, + }); + + const borPostExpiredForMe = TestHelper.fakePost({ + id: 'postid2', + channel_id: channel.id, + type: PostTypes.BURN_ON_READ, + props: {expire_at: now + 100000}, + metadata: {expire_at: now - 10000}, + }); + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [borPostExpiredForAll.id, borPostExpiredForMe.id], + posts: [borPostExpiredForAll, borPostExpiredForMe], + prepareRecordsOnly: false, + }); + + // verify channel posts + const fetchedPosts = await getPosts(serverUrl, [borPostExpiredForAll.id, borPostExpiredForMe.id]); + expect(fetchedPosts.length).toBe(2); + + await expiredBoRPostCleanup(serverUrl); + + expect(database.adapter.unsafeExecute).toHaveBeenCalledWith({ + sqls: [ + [`DELETE FROM Post where id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []], + [`DELETE FROM Reaction where post_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []], + [`DELETE FROM File where post_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []], + [`DELETE FROM Draft where root_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []], + [`DELETE FROM PostsInThread where root_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []], + [`DELETE FROM Thread where id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []], + [`DELETE FROM ThreadParticipant where thread_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []], + [`DELETE FROM ThreadsInTeam where thread_id IN ('${borPostExpiredForMe.id}','${borPostExpiredForAll.id}')`, []], + ], + }); + }); + + it('should not run cleanup when called again within 15 minutes', async () => { + const database = operator.database; + const unsafeExecuteSpy = jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve()); + + // Set up a recent last run time (within 15 minutes) + const recentRunTime = Date.now() - (10 * 60 * 1000); // 10 minutes ago + await operator.handleSystem({ + systems: [{ + id: SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN, + value: recentRunTime, + }], + prepareRecordsOnly: false, + }); + + const channel: Channel = TestHelper.fakeChannel({ + id: 'channelid1', + team_id: 'teamid1', + }); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + const now = Date.now(); + const borPostExpired = TestHelper.fakePost({ + id: 'postid1', + channel_id: channel.id, + type: PostTypes.BURN_ON_READ, + props: {expire_at: now - 10000}, + }); + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [borPostExpired.id], + posts: [borPostExpired], + prepareRecordsOnly: false, + }); + + // Call cleanup - should not run because last run was within 15 minutes + await expiredBoRPostCleanup(serverUrl); + + // Verify that unsafeExecute was not called (no cleanup performed) + expect(unsafeExecuteSpy).not.toHaveBeenCalled(); + }); + + it('should handle no server database gracefully', async () => { + // Try to run cleanup on a non-existent server + await expect(expiredBoRPostCleanup('nonexistent.server.com')).resolves.not.toThrow(); + }); + + it('should handle no BoR posts gracefully', async () => { + const database = operator.database; + const unsafeExecuteSpy = jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve()); + + const channel: Channel = TestHelper.fakeChannel({ + id: 'channelid1', + team_id: 'teamid1', + }); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + await expiredBoRPostCleanup(serverUrl); + + // Verify that unsafeExecute was not called (no BoR posts to clean) + expect(unsafeExecuteSpy).not.toHaveBeenCalled(); + }); + + it('should handle BoR posts that are not expired', async () => { + const database = operator.database; + const unsafeExecuteSpy = jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve()); + + const channel: Channel = TestHelper.fakeChannel({ + id: 'channelid1', + team_id: 'teamid1', + }); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + const now = Date.now(); + + // Create BoR posts that are not expired + const borPostNotExpired = TestHelper.fakePost({ + id: 'postid1', + channel_id: channel.id, + type: PostTypes.BURN_ON_READ, + props: {expire_at: now + 100000}, // Future expiry + }); + + const borPostNotExpiredForMe = TestHelper.fakePost({ + id: 'postid2', + channel_id: channel.id, + type: PostTypes.BURN_ON_READ, + props: {expire_at: now + 10000}, + metadata: {expire_at: now + 100000}, + }); + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [borPostNotExpired.id, borPostNotExpiredForMe.id], + posts: [borPostNotExpired, borPostNotExpiredForMe], + prepareRecordsOnly: false, + }); + + await expiredBoRPostCleanup(serverUrl); + + // Verify that unsafeExecute was not called (no expired BoR posts) + expect(unsafeExecuteSpy).not.toHaveBeenCalled(); + }); + + it('should handle mixed expired and non-expired BoR posts', async () => { + const database = operator.database; + jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve()); + + const channel: Channel = TestHelper.fakeChannel({ + id: 'channelid1', + team_id: 'teamid1', + }); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + const now = Date.now(); + + const borPostExpired = TestHelper.fakePost({ + id: 'postid1', + channel_id: channel.id, + type: PostTypes.BURN_ON_READ, + props: {expire_at: now - 10000}, // Expired + }); + + const borPostNotExpired = TestHelper.fakePost({ + id: 'postid2', + channel_id: channel.id, + type: PostTypes.BURN_ON_READ, + props: {expire_at: now + 100000}, // Not expired + }); + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [borPostExpired.id, borPostNotExpired.id], + posts: [borPostExpired, borPostNotExpired], + prepareRecordsOnly: false, + }); + + await expiredBoRPostCleanup(serverUrl); + + // Should only delete the expired post + expect(database.adapter.unsafeExecute).toHaveBeenCalledWith({ + sqls: [ + [`DELETE FROM Post where id IN ('${borPostExpired.id}')`, []], + [`DELETE FROM Reaction where post_id IN ('${borPostExpired.id}')`, []], + [`DELETE FROM File where post_id IN ('${borPostExpired.id}')`, []], + [`DELETE FROM Draft where root_id IN ('${borPostExpired.id}')`, []], + [`DELETE FROM PostsInThread where root_id IN ('${borPostExpired.id}')`, []], + [`DELETE FROM Thread where id IN ('${borPostExpired.id}')`, []], + [`DELETE FROM ThreadParticipant where thread_id IN ('${borPostExpired.id}')`, []], + [`DELETE FROM ThreadsInTeam where thread_id IN ('${borPostExpired.id}')`, []], + ], + }); + }); + + it('should handle database errors gracefully', async () => { + const database = operator.database; + + // Mock database query to throw an error + jest.spyOn(database, 'get').mockImplementation(() => { + throw new Error('Database error'); + }); + + // Should not throw an error, just log it + await expect(expiredBoRPostCleanup(serverUrl)).resolves.not.toThrow(); + expect(logError).toHaveBeenCalledWith('An error occurred while performing BoR post cleanup', expect.any(Error)); + }); + + it('should handle updateLastBoRCleanupRun error gracefully', async () => { + const database = operator.database; + jest.spyOn(database.adapter, 'unsafeExecute').mockImplementation(() => Promise.resolve()); + + // Mock handleSystem to throw an error when updating last cleanup run + const handleSystemSpy = jest.spyOn(operator, 'handleSystem').mockImplementation(() => { + throw new Error('System update error'); + }); + + const channel: Channel = TestHelper.fakeChannel({ + id: 'channelid1', + team_id: 'teamid1', + }); + await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); + + // Restore handleSystem for channel creation, then mock it again for the cleanup run update + handleSystemSpy.mockRestore(); + jest.spyOn(operator, 'handleSystem').mockImplementation((args) => { + if (args.systems?.[0]?.id === SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN) { + throw new Error('System update error'); + } + return Promise.resolve([]); + }); + + const now = Date.now(); + const borPostExpired = TestHelper.fakePost({ + id: 'postid1', + channel_id: channel.id, + type: PostTypes.BURN_ON_READ, + props: {expire_at: now - 10000}, + }); + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [borPostExpired.id], + posts: [borPostExpired], + prepareRecordsOnly: false, + }); + + // Should not throw an error, just log it + await expect(expiredBoRPostCleanup(serverUrl)).resolves.not.toThrow(); + expect(logError).toHaveBeenCalledWith('Failed updateLastBoRCleanupRun', expect.any(Error)); + }); + +}); diff --git a/app/actions/local/systems.ts b/app/actions/local/systems.ts index 639aaf67c..b790f3b02 100644 --- a/app/actions/local/systems.ts +++ b/app/actions/local/systems.ts @@ -7,11 +7,22 @@ import {DeviceEventEmitter} from 'react-native'; import {Events} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {PostTypes, BOR_POST_CLEANUP_MIN_RUN_INTERVAL} from '@constants/post'; import DatabaseManager from '@database/manager'; import {getServerCredentials} from '@init/credentials'; import {queryAllChannelsForTeam} from '@queries/servers/channel'; -import {getConfig, getLicense, getGlobalDataRetentionPolicy, getGranularDataRetentionPolicies, getLastGlobalDataRetentionRun, getIsDataRetentionEnabled} from '@queries/servers/system'; +import {queryPostsByType} from '@queries/servers/post'; +import { + getConfig, + getLicense, + getGlobalDataRetentionPolicy, + getGranularDataRetentionPolicies, + getLastGlobalDataRetentionRun, + getIsDataRetentionEnabled, + getLastBoRPostCleanupRun, +} from '@queries/servers/system'; import PostModel from '@typings/database/models/servers/post'; +import {isExpiredBoRPost} from '@utils/bor'; import {logError} from '@utils/log'; import {deletePosts} from './post'; @@ -239,7 +250,7 @@ async function dataRetentionWithoutPolicyCleanup(serverUrl: string) { } } -async function dataRetentionCleanPosts(serverUrl: string, postIds: string[]) { +export async function dataRetentionCleanPosts(serverUrl: string, postIds: string[]) { if (postIds.length) { const batchSize = 1000; const deletePromises = []; @@ -314,3 +325,55 @@ export async function dismissAnnouncement(serverUrl: string, announcementText: s return {error}; } } + +export async function expiredBoRPostCleanup(serverUrl: string) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const lastRunAt = await getLastBoRPostCleanupRun(database); + + const shouldRunNow = (Date.now() - lastRunAt) > BOR_POST_CLEANUP_MIN_RUN_INTERVAL; + + if (!shouldRunNow) { + return; + } + + const {error} = await removeExpiredBoRPosts(serverUrl); + if (!error) { + await updateLastBoRCleanupRun(serverUrl); + } + } catch (error) { + logError('An error occurred running the Burn on Read cleanup task', error); + } +} + +async function removeExpiredBoRPosts(serverUrl: string) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const allBoRPosts = await queryPostsByType(database, PostTypes.BURN_ON_READ).fetch(); + const expiredBoRPostIDs = allBoRPosts. + filter((post) => isExpiredBoRPost(post)). + map((post) => post.id); + + await dataRetentionCleanPosts(serverUrl, expiredBoRPostIDs); + return {error: undefined}; + } catch (error) { + logError('An error occurred while performing BoR post cleanup', error); + return {error}; + } +} + +async function updateLastBoRCleanupRun(serverUrl: string) { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const systems: IdValue[] = [{ + id: SYSTEM_IDENTIFIERS.LAST_BOR_POST_CLEANUP_RUN, + value: Date.now(), + }]; + + return operator.handleSystem({systems, prepareRecordsOnly: false}); + } catch (error) { + logError('Failed updateLastBoRCleanupRun', error); + return {error}; + } +} diff --git a/app/actions/remote/post.test.ts b/app/actions/remote/post.test.ts index 9b148ddab..652068a90 100644 --- a/app/actions/remote/post.test.ts +++ b/app/actions/remote/post.test.ts @@ -19,6 +19,7 @@ import { editPost, acknowledgePost, unacknowledgePost, + revealBoRPost, fetchPostsForChannel, fetchPostsForUnreadChannels, fetchPosts, @@ -100,6 +101,17 @@ const mockClient = { patchPost: jest.fn((message: string, postId: string) => ({...post1, id: postId, message})), acknowledgePost: jest.fn(() => ({acknowledged_at: acknowledgedTime})), unacknowledgePost: jest.fn(), + revealBoRPost: jest.fn((_postId: string) => ({ + ...post1, + id: _postId, + message: 'Revealed message content', + metadata: { + files: [{id: 'file1', name: 'revealed-file.pdf'}], + }, + props: { + revealed: true, + }, + })), getPosts: genericGetPostsMock, getPostsBefore: genericGetPostsMock, getPostsSince: jest.fn((_channelId: string, since: number) => ({posts: {[post1.id]: {...post1, channel_id: _channelId, create_at: since + 1}, [post2.id]: {...post2, channel_id: _channelId, create_at: since + 2}}, order: [post1.id, post2.id]})), @@ -1112,3 +1124,54 @@ describe('get posts', () => { expect(result.posts?.length).toBe(2); }); }); + +describe('revealBoRPost', () => { + it('revealBoRPost - handle database not found', async () => { + const result = await revealBoRPost('foo', ''); + expect(result).toBeDefined(); + expect(result.error).toBeTruthy(); + }); + + it('revealBoRPost - handle client error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await revealBoRPost(serverUrl, post1.id); + expect(result).toBeDefined(); + expect(result.error).toBeTruthy(); + }); + + it('revealBoRPost - handle error', async () => { + mockClient.revealBoRPost.mockImplementationOnce(jest.fn(throwFunc)); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post1.id], + posts: [{...post1, props: {failed: false}}], + prepareRecordsOnly: false, + }); + + const result = await revealBoRPost(serverUrl, post1.id); + expect(result).toBeDefined(); + expect(result.error).toBeTruthy(); + }); + + it('revealBoRPost - base case', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post1.id], + posts: [post1], + prepareRecordsOnly: false, + }); + + const result = await revealBoRPost(serverUrl, post1.id); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.post).toBeDefined(); + }); + + it('revealBoRPost - post not found', async () => { + const result = await revealBoRPost(serverUrl, 'nonexistent-post-id'); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.post).toBeUndefined(); + }); +}); diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 01358c99d..d528183d6 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -958,6 +958,32 @@ export const editPost = async (serverUrl: string, postId: string, postMessage: s } }; +export const revealBoRPost = async (serverUrl: string, postId: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const post = await getPostById(database, postId); + if (!post) { + return {post: undefined}; + } + + const revealedPost = await client.revealBoRPost(postId); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [revealedPost.id], + posts: [revealedPost], + prepareRecordsOnly: false, + }); + + return {post}; + } catch (error) { + logDebug('error on revealBoRPost', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + export async function fetchSavedPosts(serverUrl: string, teamId?: string, channelId?: string, page?: number, perPage?: number) { try { const client = NetworkManager.getClient(serverUrl); diff --git a/app/actions/websocket/burn_on_read.test.ts b/app/actions/websocket/burn_on_read.test.ts new file mode 100644 index 000000000..a07c1b5b9 --- /dev/null +++ b/app/actions/websocket/burn_on_read.test.ts @@ -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(); + }); + }); +}); diff --git a/app/actions/websocket/burn_on_read.ts b/app/actions/websocket/burn_on_read.ts new file mode 100644 index 000000000..ec9f777eb --- /dev/null +++ b/app/actions/websocket/burn_on_read.ts @@ -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}; + } +} diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index 762cfb5b7..e7cdbaa0c 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -4,6 +4,7 @@ import {handleAgentPostUpdate} from '@agents/actions/websocket'; import * as bookmark from '@actions/local/channel_bookmark'; +import {handleBoRPostRevealedEvent} from '@actions/websocket/burn_on_read'; import * as scheduledPost from '@actions/websocket/scheduled_post'; import * as calls from '@calls/connection/websocket_event_handlers'; import {WebsocketEvents} from '@constants'; @@ -308,6 +309,11 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess case WebsocketEvents.AGENTS_POST_UPDATE: handleAgentPostUpdate(msg); break; + + // Burn on Read Events + case WebsocketEvents.BOR_POST_REVEALED: + handleBoRPostRevealedEvent(serverUrl, msg); + break; } handlePlaybookEvents(serverUrl, msg); } diff --git a/app/actions/websocket/index.test.ts b/app/actions/websocket/index.test.ts index f7bb9c057..d342fdf96 100644 --- a/app/actions/websocket/index.test.ts +++ b/app/actions/websocket/index.test.ts @@ -4,7 +4,7 @@ import {DeviceEventEmitter} from 'react-native'; import {markChannelAsViewed} from '@actions/local/channel'; -import {dataRetentionCleanup} from '@actions/local/systems'; +import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems'; import {markChannelAsRead} from '@actions/remote/channel'; import {entry, handleEntryAfterLoadNavigation} from '@actions/remote/entry/common'; import {deferredAppEntryActions} from '@actions/remote/entry/deferred'; @@ -160,6 +160,7 @@ describe('WebSocket Index Actions', () => { expect(deferredAppEntryActions).toHaveBeenCalled(); expect(openAllUnreadChannels).toHaveBeenCalled(); expect(dataRetentionCleanup).toHaveBeenCalled(); + expect(expiredBoRPostCleanup).toHaveBeenCalled(); expect(AppsManager.refreshAppBindings).toHaveBeenCalled(); expect(handlePlaybookReconnect).toHaveBeenCalledWith(serverUrl); }); diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 221970306..13b2a171e 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {markChannelAsViewed} from '@actions/local/channel'; -import {dataRetentionCleanup} from '@actions/local/systems'; +import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems'; import {markChannelAsRead} from '@actions/remote/channel'; import { entry, @@ -104,6 +104,8 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel dataRetentionCleanup(serverUrl); + expiredBoRPostCleanup(serverUrl); + AppsManager.refreshAppBindings(serverUrl, groupLabel); return undefined; } diff --git a/app/client/rest/posts.test.ts b/app/client/rest/posts.test.ts index 9eb72a1bf..99e334c6c 100644 --- a/app/client/rest/posts.test.ts +++ b/app/client/rest/posts.test.ts @@ -308,4 +308,13 @@ describe('ClientPosts', () => { {method: 'post'}, ); }); + + test('revealBoRPost', async () => { + await client.revealBoRPost('post_id'); + + expect(client.doFetch).toHaveBeenCalledWith( + `${client.getPostRoute('post_id')}/reveal`, + {method: 'get'}, + ); + }); }); diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 5635ca5b3..920aa7c1d 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -34,6 +34,7 @@ export interface ClientPostsMix { acknowledgePost: (postId: string, userId: string) => Promise; unacknowledgePost: (postId: string, userId: string) => Promise; sendTestNotification: () => Promise<{status: 'OK'}>; + revealBoRPost: (postId: string) => Promise; } const ClientPosts = >(superclass: TBase) => class extends superclass { @@ -228,6 +229,13 @@ const ClientPosts = >(superclass: TBase) = {method: 'post'}, ); }; + + revealBoRPost = async (postId: string) => { + return this.doFetch( + `${this.getPostRoute(postId)}/reveal`, + {method: 'get'}, + ); + }; }; export default ClientPosts; diff --git a/app/components/post_list/post/burn_on_read/unrevealed/constants.ts b/app/components/post_list/post/burn_on_read/unrevealed/constants.ts new file mode 100644 index 000000000..3d0b79df1 --- /dev/null +++ b/app/components/post_list/post/burn_on_read/unrevealed/constants.ts @@ -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, +]; diff --git a/app/components/post_list/post/burn_on_read/unrevealed/index.test.tsx b/app/components/post_list/post/burn_on_read/unrevealed/index.test.tsx new file mode 100644 index 000000000..46d93e0d1 --- /dev/null +++ b/app/components/post_list/post/burn_on_read/unrevealed/index.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + 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(); + + const button = screen.getByText('View message'); + + await act(async () => { + fireEvent.press(button); + }); + + expect(removePost).not.toHaveBeenCalled(); + expect(showBoRPostErrorSnackbar).toHaveBeenCalledWith('Unexpected server error'); + }); +}); diff --git a/app/components/post_list/post/burn_on_read/unrevealed/index.tsx b/app/components/post_list/post/burn_on_read/unrevealed/index.tsx new file mode 100644 index 000000000..55d6a7514 --- /dev/null +++ b/app/components/post_list/post/burn_on_read/unrevealed/index.tsx @@ -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 ( +