diff --git a/app/actions/local/draft.test.ts b/app/actions/local/draft.test.ts index d0369e5c3..92bfd4f12 100644 --- a/app/actions/local/draft.test.ts +++ b/app/actions/local/draft.test.ts @@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native'; import {Navigation, Screens} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft'; +import {PostTypes} from '@constants/post'; import DatabaseManager from '@database/manager'; import {goToScreen, popTo} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; @@ -19,6 +20,7 @@ import { addFilesToDraft, removeDraft, updateDraftPriority, + updateDraftBoRConfig, updateDraftMarkdownImageMetadata, } from './draft'; @@ -405,3 +407,47 @@ describe('updateDraftMarkdownImageMetadata', () => { expect(result.draft.metadata?.images?.image1).toEqual(postImageData); }); }); + +describe('updateDraftBoRConfig', () => { + const postBoRConfig: PostBoRConfig = { + enabled: true, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + }; + + it('handle not found database', async () => { + const result = await updateDraftBoRConfig('foo', channelId, '', postBoRConfig) as {draft: unknown; error: unknown}; + expect(result.error).toBeTruthy(); + }); + + it('handle no draft', async () => { + const models = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as DraftModel[]; + expect(models).toBeDefined(); + expect(models.length).toBe(1); + expect(models[0].metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled); + expect(models[0].metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds); + expect(models[0].type).toBe(PostTypes.BURN_ON_READ); + }); + + it('update draft BoR config with enabled true', async () => { + await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false}); + + const result = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as {draft: DraftModel; error: unknown}; + expect(result.error).toBeUndefined(); + expect(result.draft).toBeDefined(); + expect(result.draft.metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled); + expect(result.draft.metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds); + expect(result.draft.type).toBe('burn_on_read'); + }); + + it('update draft BoR config with enabled false', async () => { + await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false}); + + const disabledBoRConfig = {...postBoRConfig, enabled: false}; + const result = await updateDraftBoRConfig(serverUrl, channelId, '', disabledBoRConfig) as {draft: DraftModel; error: unknown}; + expect(result.error).toBeUndefined(); + expect(result.draft).toBeDefined(); + expect(result.draft.metadata?.borConfig?.enabled).toBe(false); + expect(result.draft.type).toBe(''); + }); +}); diff --git a/app/actions/local/draft.ts b/app/actions/local/draft.ts index 358d1db77..58afd7b09 100644 --- a/app/actions/local/draft.ts +++ b/app/actions/local/draft.ts @@ -5,6 +5,7 @@ import {Image} from 'expo-image'; import {DeviceEventEmitter} from 'react-native'; import {Navigation, Screens} from '@constants'; +import {PostTypes} from '@constants/post'; import DatabaseManager from '@database/manager'; import {getDraft} from '@queries/servers/drafts'; import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system'; @@ -265,6 +266,53 @@ export async function updateDraftPriority(serverUrl: string, channelId: string, } } +export async function updateDraftBoRConfig(serverUrl: string, channelId: string, rootId: string, postBoRConfig: PostBoRConfig, prepareRecordsOnly = false) { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const draft = await getDraft(database, channelId, rootId); + if (!draft) { + const newDraft: Draft = { + channel_id: channelId, + root_id: rootId, + update_at: Date.now(), + metadata: { + borConfig: postBoRConfig, + }, + }; + + if (postBoRConfig.enabled) { + newDraft.type = PostTypes.BURN_ON_READ; + } else { + newDraft.type = ''; + } + + return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly}); + } + + draft?.prepareUpdate((d) => { + d.metadata = { + ...d.metadata, + borConfig: postBoRConfig, + }; + + if (postBoRConfig.enabled) { + d.type = PostTypes.BURN_ON_READ; + } else { + d.type = ''; + } + }); + + if (!prepareRecordsOnly) { + await operator.batchRecords([draft], 'updateDraftBoRConfig'); + } + + return {draft}; + } catch (error) { + logError('Failed updateDraftBoRConfig', error); + return {error}; + } +} + export async function updateDraftMarkdownImageMetadata({ serverUrl, channelId, diff --git a/app/actions/remote/post.test.ts b/app/actions/remote/post.test.ts index 652068a90..e9ec6b27f 100644 --- a/app/actions/remote/post.test.ts +++ b/app/actions/remote/post.test.ts @@ -9,6 +9,7 @@ import DatabaseManager from '@database/manager'; import PostModel from '@database/models/server/post'; import NetworkManager from '@managers/network_manager'; import TestHelper from '@test/test_helper'; +import {getFullErrorMessage} from '@utils/errors'; import { createPost, @@ -32,6 +33,7 @@ import { fetchPostById, fetchSavedPosts, fetchPinnedPosts, + burnPostNow, } from './post'; import * as PostAuxilaryFunctions from './post.auxiliary'; @@ -94,6 +96,7 @@ const mockClient = { pinPost: jest.fn(), unpinPost: jest.fn(), deletePost: jest.fn(), + burnPostNow: jest.fn(), getChannel: jest.fn((_channelId: string) => ({id: _channelId, name: 'channel1', creatorId: user1.id, total_msg_count: 100})), getChannelMember: jest.fn((_channelId: string, userId: string) => ({id: userId + '-' + _channelId, user_id: userId, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})), getMyChannelMember: jest.fn((_channelId: string) => ({id: user1.id + '-' + _channelId, user_id: user1.id, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})), @@ -382,6 +385,51 @@ describe('create, update & delete posts', () => { expect(result.post).toBeDefined(); }); + it('burnPostNow - handle error', async () => { + mockClient.burnPostNow.mockImplementationOnce(jest.fn(throwFunc)); + const result = await burnPostNow('foo', {} as PostModel); + expect(result).toBeDefined(); + expect(result.error).toBeTruthy(); + }); + + it('burnPostNow - base case', async () => { + mockClient.burnPostNow.mockReset(); + const borPost = TestHelper.fakePost({channel_id: channelId, id: 'bor_postid1', user_id: user1.id, type: 'burn_on_read'}); + + const postModels = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [borPost.id], + posts: [borPost], + prepareRecordsOnly: false, + }); + + // verify post exists in database before burn + const {database} = operator; + const existingPost = await database.get('Post').find(borPost.id); + expect(existingPost).toBeDefined(); + + const result = await burnPostNow(serverUrl, postModels[0] as PostModel); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.post).toBeDefined(); + + // Verify that the post is deleted from the database + try { + await database.get('Post').find(borPost.id); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + expect(getFullErrorMessage(error)).toBe('Record Post#bor_postid1 not found'); + } + }); + + it('burnPostNow - returns error for non-BoR posts', async () => { + const result = await burnPostNow('foo', {} as PostModel); + expect(result).toBeDefined(); + expect(result.error).toBeTruthy(); + expect(result.error).toBe('Post is not a Burn-on-Read post'); + }); + it('deletePost - system post', async () => { const postModels = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index d528183d6..7e8d38cd0 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -23,6 +23,7 @@ import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers import {queryAllUsers} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import {setFetchingThreadState} from '@store/fetching_thread_store'; +import {isBoRPost} from '@utils/bor'; import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers'; import {getFullErrorMessage, isServerError} from '@utils/errors'; import {hasArrayChanged} from '@utils/helpers'; @@ -873,6 +874,24 @@ export const deletePost = async (serverUrl: string, postToDelete: PostModel | Po } }; +export const burnPostNow = async (serverUrl: string, postToBurn: PostModel | Post) => { + if (!isBoRPost(postToBurn)) { + return {error: 'Post is not a Burn-on-Read post'}; + } + + try { + const client = NetworkManager.getClient(serverUrl); + await client.burnPostNow(postToBurn.id); + + const post = await removePost(serverUrl, postToBurn); + return {post}; + } catch (error) { + logDebug('error on burnPostNow', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + export const markPostAsUnread = async (serverUrl: string, postId: string) => { try { const client = NetworkManager.getClient(serverUrl); diff --git a/app/actions/websocket/burn_on_read.test.ts b/app/actions/websocket/burn_on_read.test.ts index 3953f7c26..f994ea9dd 100644 --- a/app/actions/websocket/burn_on_read.test.ts +++ b/app/actions/websocket/burn_on_read.test.ts @@ -3,16 +3,21 @@ import {removePost} from '@actions/local/post'; import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts'; +import {ActionType} from '@constants'; import {PostTypes} from '@constants/post'; import DatabaseManager from '@database/manager'; import {getPostById} from '@queries/servers/post'; +import {getCurrentUser} from '@queries/servers/user'; import TestHelper from '@test/test_helper'; -import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent} from './burn_on_read'; +import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent, handleBoRPostAllRevealed} from './burn_on_read'; + +import type {ServerDatabase} from '@typings/database/database'; jest.mock('@actions/websocket/posts'); jest.mock('@queries/servers/post'); jest.mock('@actions/local/post'); +jest.mock('@queries/servers/user'); const serverUrl = 'burnOnRead.test.com'; @@ -23,6 +28,7 @@ describe('WebSocket Burn on Read Actions', () => { const mockedHandleNewPostEvent = jest.mocked(handleNewPostEvent); const mockedHandlePostEdited = jest.mocked(handlePostEdited); const mockedRemovePost = jest.mocked(removePost); + const mockedGetCurrentUser = jest.mocked(getCurrentUser); beforeEach(async () => { await DatabaseManager.init([serverUrl]); @@ -57,10 +63,149 @@ describe('WebSocket Burn on Read Actions', () => { await handleBoRPostRevealedEvent(serverUrl, msg); expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1'); - expect(mockedHandlePostEdited).toHaveBeenCalledWith(serverUrl, msg); expect(mockedHandleNewPostEvent).not.toHaveBeenCalled(); }); + it('should merge recipients from message with existing post recipients', async () => { + const existingPost = TestHelper.fakePostModel({ + id: 'post1', + metadata: {recipients: ['user1', 'user2']}, + }); + mockedGetPostById.mockResolvedValue(existingPost); + + const msgWithRecipients = { + data: { + post: JSON.stringify(post), + recipients: ['user3', 'user4'], + }, + } as WebSocketMessage; + + await handleBoRPostRevealedEvent(serverUrl, msgWithRecipients); + + expect(mockedHandlePostEdited).toHaveBeenCalled(); + const calledMsg = mockedHandlePostEdited.mock.calls[0][1]; + const updatedPost = JSON.parse(calledMsg.data.post); + expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2', 'user3', 'user4']); + }); + + it('should deduplicate recipients when merging', async () => { + const existingPost = TestHelper.fakePostModel({ + id: 'post1', + metadata: {recipients: ['user1', 'user2']}, + }); + mockedGetPostById.mockResolvedValue(existingPost); + + const msgWithDuplicates = { + data: { + post: JSON.stringify(post), + recipients: ['user2', 'user3'], + }, + } as WebSocketMessage; + + await handleBoRPostRevealedEvent(serverUrl, msgWithDuplicates); + + expect(mockedHandlePostEdited).toHaveBeenCalled(); + const calledMsg = mockedHandlePostEdited.mock.calls[0][1]; + const updatedPost = JSON.parse(calledMsg.data.post); + expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2', 'user3']); + }); + + it('should handle existing post without recipients metadata', async () => { + const existingPost = TestHelper.fakePostModel({ + id: 'post1', + metadata: {}, + }); + mockedGetPostById.mockResolvedValue(existingPost); + + const msgWithRecipients = { + data: { + post: JSON.stringify(post), + recipients: ['user1', 'user2'], + }, + } as WebSocketMessage; + + await handleBoRPostRevealedEvent(serverUrl, msgWithRecipients); + + expect(mockedHandlePostEdited).toHaveBeenCalled(); + const calledMsg = mockedHandlePostEdited.mock.calls[0][1]; + const updatedPost = JSON.parse(calledMsg.data.post); + expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']); + }); + + it('should preserve existing post metadata when adding recipients', async () => { + const postWithMetadata = TestHelper.fakePost({ + id: 'post1', + channel_id: 'channel1', + user_id: 'user1', + create_at: 12345, + message: 'hello', + metadata: {embeds: [{type: 'permalink'} as PostEmbed]}, + }); + const existingPost = TestHelper.fakePostModel({ + id: 'post1', + metadata: {recipients: ['user1']}, + }); + mockedGetPostById.mockResolvedValue(existingPost); + + const msgWithMetadata = { + data: { + post: JSON.stringify(postWithMetadata), + recipients: ['user2'], + }, + } as WebSocketMessage; + + await handleBoRPostRevealedEvent(serverUrl, msgWithMetadata); + + expect(mockedHandlePostEdited).toHaveBeenCalled(); + const calledMsg = mockedHandlePostEdited.mock.calls[0][1]; + const updatedPost = JSON.parse(calledMsg.data.post); + expect(updatedPost.metadata.embeds).toEqual([{type: 'permalink'}]); + expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']); + }); + + it('should handle empty recipients array in websocket message', async () => { + const existingPost = TestHelper.fakePostModel({ + id: 'post1', + metadata: {recipients: ['user1', 'user2']}, + }); + mockedGetPostById.mockResolvedValue(existingPost); + + const msgWithEmptyRecipients = { + data: { + post: JSON.stringify(post), + recipients: [], + }, + } as WebSocketMessage; + + await handleBoRPostRevealedEvent(serverUrl, msgWithEmptyRecipients); + + expect(mockedHandlePostEdited).toHaveBeenCalled(); + const calledMsg = mockedHandlePostEdited.mock.calls[0][1]; + const updatedPost = JSON.parse(calledMsg.data.post); + expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']); + }); + + it('should handle undefined recipients in websocket message', async () => { + const existingPost = TestHelper.fakePostModel({ + id: 'post1', + metadata: {recipients: ['user1', 'user2']}, + }); + mockedGetPostById.mockResolvedValue(existingPost); + + const msgWithoutRecipients = { + data: { + post: JSON.stringify(post), + }, + } as WebSocketMessage; + + await handleBoRPostRevealedEvent(serverUrl, msgWithoutRecipients); + + expect(mockedHandlePostEdited).toHaveBeenCalled(); + const calledMsg = mockedHandlePostEdited.mock.calls[0][1]; + const updatedPost = JSON.parse(calledMsg.data.post); + expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']); + }); + it('should handle malformed post data gracefully', async () => { const malformedMsg = { data: { @@ -186,4 +331,115 @@ describe('WebSocket Burn on Read Actions', () => { expect(mockedRemovePost).not.toHaveBeenCalled(); }); }); + + describe('handleBoRPostAllRevealed', () => { + const currentUser = TestHelper.fakeUserModel({id: 'user1'}); + const burnOnReadPost = TestHelper.fakePostModel({ + id: 'post1', + type: PostTypes.BURN_ON_READ, + userId: 'user1', + }); + + const msg = { + data: { + post_id: 'post1', + sender_expire_at: 67890, + }, + } as WebSocketMessage; + + beforeEach(() => { + const mockOperator = { + database: {}, + handlePosts: jest.fn().mockResolvedValue({}), + }; + DatabaseManager.serverDatabases[serverUrl] = {operator: mockOperator} as unknown as ServerDatabase; + }); + + it('should update post with expire_at when user owns the burn-on-read post', async () => { + const mockToApi = jest.fn().mockResolvedValue({ + id: 'post1', + type: PostTypes.BURN_ON_READ, + user_id: 'user1', + metadata: {}, + }); + burnOnReadPost.toApi = mockToApi; + + mockedGetPostById.mockResolvedValue(burnOnReadPost); + mockedGetCurrentUser.mockResolvedValue(currentUser); + + const result = await handleBoRPostAllRevealed(serverUrl, msg); + + expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1'); + expect(mockedGetCurrentUser).toHaveBeenCalledWith(expect.any(Object)); + expect(mockToApi).toHaveBeenCalled(); + expect(DatabaseManager.serverDatabases[serverUrl]?.operator?.handlePosts).toHaveBeenCalledWith({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: ['post1'], + posts: [{ + id: 'post1', + type: PostTypes.BURN_ON_READ, + user_id: 'user1', + metadata: {expire_at: 67890}, + }], + prepareRecordsOnly: false, + }); + expect(result).toHaveProperty('post'); + expect(result!.post!.metadata.expire_at).toBe(67890); + }); + + it('should return null when post does not exist locally', async () => { + mockedGetPostById.mockResolvedValue(undefined); + + const result = await handleBoRPostAllRevealed(serverUrl, msg); + + expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1'); + expect(mockedGetCurrentUser).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should return null when user does not own the post', async () => { + const otherUsersBoRPost = TestHelper.fakePostModel({ + id: 'post1', + type: PostTypes.BURN_ON_READ, + userId: 'otheruser1', + }); + mockedGetPostById.mockResolvedValue(otherUsersBoRPost); + mockedGetCurrentUser.mockResolvedValue(currentUser); + + const result = await handleBoRPostAllRevealed(serverUrl, msg); + + expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1'); + expect(mockedGetCurrentUser).toHaveBeenCalledWith(expect.any(Object)); + expect(DatabaseManager.serverDatabases[serverUrl]?.operator?.handlePosts).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should handle missing server database gracefully', async () => { + const result = await handleBoRPostAllRevealed('invalid-server-url', msg); + + expect(mockedGetPostById).not.toHaveBeenCalled(); + expect(mockedGetCurrentUser).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should handle missing operator gracefully', async () => { + DatabaseManager.serverDatabases[serverUrl] = {} as any; + + const result = await handleBoRPostAllRevealed(serverUrl, msg); + + expect(mockedGetPostById).not.toHaveBeenCalled(); + expect(mockedGetCurrentUser).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should handle errors gracefully and return error object', async () => { + mockedGetPostById.mockRejectedValue(new Error('Database error')); + + const result = await handleBoRPostAllRevealed(serverUrl, msg); + + expect(result).toHaveProperty('error'); + expect(result!.error).toBeInstanceOf(Error); + expect(mockedGetCurrentUser).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/actions/websocket/burn_on_read.ts b/app/actions/websocket/burn_on_read.ts index 59d4eb819..cdfbcfd23 100644 --- a/app/actions/websocket/burn_on_read.ts +++ b/app/actions/websocket/burn_on_read.ts @@ -3,9 +3,12 @@ import {removePost} from '@actions/local/post'; import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts'; +import {ActionType} from '@constants'; import {PostTypes} from '@constants/post'; import DatabaseManager from '@database/manager'; import {getPostById} from '@queries/servers/post'; +import {getCurrentUser} from '@queries/servers/user'; +import {isOwnBoRPost} from '@utils/bor'; import {logError} from '@utils/log'; export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSocketMessage) { @@ -17,6 +20,7 @@ export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSock const {database} = operator; let post: Post; + const recipients = msg.data.recipients || []; try { post = JSON.parse(msg.data.post); } catch { @@ -24,7 +28,18 @@ export async function handleBoRPostRevealedEvent(serverUrl: string, msg: WebSock } const existingPost = await getPostById(database, post.id); + if (existingPost) { + // Add the receipt to post metadata and update in websocket message so handlePostEdited can get + // the updated list of recipients + const existingRecipients = existingPost.metadata?.recipients || []; + const updatedRecipients = Array.from(new Set([...existingRecipients, ...recipients])); + post.metadata = { + ...post.metadata, + recipients: updatedRecipients, + }; + msg.data.post = JSON.stringify(post); + await handlePostEdited(serverUrl, msg); } else { await handleNewPostEvent(serverUrl, msg); @@ -62,3 +77,41 @@ export async function handleBoRPostBurnedEvent(serverUrl: string, msg: WebSocket return {error}; } } +export async function handleBoRPostAllRevealed(serverUrl: string, msg: WebSocketMessage) { + try { + const postId = msg.data.post_id; + const expireAt = msg.data.sender_expire_at; + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return null; + } + + const {database} = operator; + const post = await getPostById(database, postId); + if (!post) { + return null; + } + + const currentUser = await getCurrentUser(database); + if (!isOwnBoRPost(post, currentUser?.id)) { + return null; + } + + // This converts PostModel to Post + const updatedPost: Post = await post.toApi(); + updatedPost.metadata.expire_at = expireAt; + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [updatedPost.id], + posts: [updatedPost], + prepareRecordsOnly: false, + }); + + return {post: updatedPost}; + } catch (error) { + logError('handleBoRPostAllRevealed could not handle websocket event for all revealed burn-on-read posts', error); + return {error}; + } +} diff --git a/app/actions/websocket/event.test.ts b/app/actions/websocket/event.test.ts index df57a2239..d9051a0be 100644 --- a/app/actions/websocket/event.test.ts +++ b/app/actions/websocket/event.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import * as bookmark from '@actions/local/channel_bookmark'; +import * as burnOnRead from '@actions/websocket/burn_on_read'; import * as scheduledPost from '@actions/websocket/scheduled_post'; import * as calls from '@calls/connection/websocket_event_handlers'; import {WebsocketEvents} from '@constants'; @@ -36,6 +37,7 @@ jest.mock('@calls/connection/websocket_event_handlers'); jest.mock('./group'); jest.mock('@actions/local/channel_bookmark'); jest.mock('@actions/websocket/scheduled_post'); +jest.mock('@actions/websocket/burn_on_read'); jest.mock('@playbooks/actions/websocket/events'); describe('handleWebSocketEvent', () => { @@ -526,6 +528,24 @@ describe('handleWebSocketEvent', () => { expect(scheduledPost.handleDeleteScheduledPost).toHaveBeenCalledWith(serverUrl, msg); }); + it('should handle BOR_POST_REVEALED event', async () => { + msg.event = WebsocketEvents.BOR_POST_REVEALED; + await handleWebSocketEvent(serverUrl, msg); + expect(burnOnRead.handleBoRPostRevealedEvent).toHaveBeenCalledWith(serverUrl, msg); + }); + + it('should handle BOR_POST_BURNED event', async () => { + msg.event = WebsocketEvents.BOR_POST_BURNED; + await handleWebSocketEvent(serverUrl, msg); + expect(burnOnRead.handleBoRPostBurnedEvent).toHaveBeenCalledWith(serverUrl, msg); + }); + + it('should handle BURN_ON_READ_ALL_REVEALED event', async () => { + msg.event = WebsocketEvents.BURN_ON_READ_ALL_REVEALED; + await handleWebSocketEvent(serverUrl, msg); + expect(burnOnRead.handleBoRPostAllRevealed).toHaveBeenCalledWith(serverUrl, msg); + }); + it('all messages should go through the playbooks handler', async () => { msg.event = WebsocketEvents.POST_DELETED; // any handled event should be enough await handleWebSocketEvent(serverUrl, msg); diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index ba731545f..7bf4afce7 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -4,7 +4,11 @@ import {handleAgentPostUpdate} from '@agents/actions/websocket'; import * as bookmark from '@actions/local/channel_bookmark'; -import {handleBoRPostBurnedEvent, handleBoRPostRevealedEvent} from '@actions/websocket/burn_on_read'; +import { + handleBoRPostAllRevealed, + handleBoRPostBurnedEvent, + handleBoRPostRevealedEvent, +} from '@actions/websocket/burn_on_read'; import * as scheduledPost from '@actions/websocket/scheduled_post'; import * as calls from '@calls/connection/websocket_event_handlers'; import {WebsocketEvents} from '@constants'; @@ -317,6 +321,9 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess case WebsocketEvents.BOR_POST_BURNED: handleBoRPostBurnedEvent(serverUrl, msg); break; + case WebsocketEvents.BURN_ON_READ_ALL_REVEALED: + handleBoRPostAllRevealed(serverUrl, msg); + break; } handlePlaybookEvents(serverUrl, msg); } diff --git a/app/client/rest/posts.test.ts b/app/client/rest/posts.test.ts index 99e334c6c..cdd584440 100644 --- a/app/client/rest/posts.test.ts +++ b/app/client/rest/posts.test.ts @@ -67,6 +67,16 @@ describe('ClientPosts', () => { ); }); + test('burnPostNow', async () => { + const postId = 'post_id'; + await client.burnPostNow(postId); + + expect(client.doFetch).toHaveBeenCalledWith( + client.getPostRoute(postId) + '/burn', + {method: 'delete'}, + ); + }); + test('getPostThread', async () => { const postId = 'post_id'; const options = {fetchThreads: true, collapsedThreads: false, collapsedThreadsExtended: false, direction: 'up'} as FetchPaginatedThreadOptions; diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 920aa7c1d..83f156e07 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -13,6 +13,7 @@ export interface ClientPostsMix { getPost: (postId: string, groupLabel?: RequestGroupLabel) => Promise; patchPost: (postPatch: Partial & {id: string}) => Promise; deletePost: (postId: string) => Promise; + burnPostNow: (postId: string) => Promise; getPostThread: (postId: string, options: FetchPaginatedThreadOptions, groupLabel?: RequestGroupLabel) => Promise; getPosts: (channelId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean, groupLabel?: RequestGroupLabel) => Promise; getPostsSince: (channelId: string, since: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean, groupLabel?: RequestGroupLabel) => Promise; @@ -73,6 +74,13 @@ const ClientPosts = >(superclass: TBase) = ); }; + burnPostNow = async (postId: string) => { + return this.doFetch( + `${this.getPostRoute(postId)}/burn`, + {method: 'delete'}, + ); + }; + getPostThread = (postId: string, options: FetchPaginatedThreadOptions, groupLabel?: RequestGroupLabel) => { const { fetchThreads = true, diff --git a/app/components/burn_on_read_label/index.tsx b/app/components/burn_on_read_label/index.tsx index dba67c49f..59e83f273 100644 --- a/app/components/burn_on_read_label/index.tsx +++ b/app/components/burn_on_read_label/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useMemo} from 'react'; import {useIntl} from 'react-intl'; import Tag from '@components/tag'; @@ -13,12 +13,15 @@ type Props = { } export default function BoRLabel({durationSeconds, id}: Props) { - const {formatMessage} = useIntl(); + const intl = useIntl(); - const message = formatMessage({ - id: 'burn_on_read.label.title', - defaultMessage: 'BURN ON READ ({duration})', - }, {duration: formatTime(durationSeconds, true)}); + const message = useMemo(() => { + const duration = formatTime(durationSeconds, true, intl); + return intl.formatMessage({ + id: 'burn_on_read.label.title', + defaultMessage: 'BURN ON READ ({duration})', + }, {duration}); + }, [durationSeconds, intl]); return ( >; value: string; setIsFocused: (isFocused: boolean) => void; + location?: AvailableScreens; } const emptyFileList: FileInfo[] = []; @@ -49,6 +51,7 @@ export default function DraftHandler(props: Props) { updateValue, value, setIsFocused, + location, } = props; const serverUrl = useServerUrl(); @@ -135,6 +138,7 @@ export default function DraftHandler(props: Props) { updatePostInputTop={updatePostInputTop} updateValue={updateValue} setIsFocused={setIsFocused} + location={location} /> ); } diff --git a/app/components/post_draft/draft_input/draft_input.test.tsx b/app/components/post_draft/draft_input/draft_input.test.tsx index 5ec896e48..b79ea281f 100644 --- a/app/components/post_draft/draft_input/draft_input.test.tsx +++ b/app/components/post_draft/draft_input/draft_input.test.tsx @@ -1,10 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {act} from '@testing-library/react-hooks'; import {fireEvent} from '@testing-library/react-native'; import React from 'react'; -import {Screens} from '@constants'; +import {License, Screens} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; import {PostPriorityType} from '@constants/post'; import NetworkManager from '@managers/network_manager'; import {openAsBottomSheet} from '@screens/navigation'; @@ -40,6 +42,7 @@ describe('DraftInput', () => { currentUserId: 'currentUserId', postPriority: {priority: ''} as PostPriority, updatePostPriority: jest.fn(), + updatePostBoRStatus: jest.fn(), persistentNotificationInterval: 0, persistentNotificationMaxRecipients: 0, updateCursorPosition: jest.fn(), @@ -55,6 +58,7 @@ describe('DraftInput', () => { updatePostInputTop: jest.fn(), setIsFocused: jest.fn(), scheduledPostsEnabled: true, + location: Screens.CHANNEL, }; beforeEach(() => { @@ -210,4 +214,117 @@ describe('DraftInput', () => { expect(baseProps.sendMessage).not.toHaveBeenCalled(); }); }); + + describe('BoR (Burn on Read) Functionality', () => { + + it('renders with BoR config disabled by default', () => { + const {getByTestId, queryByTestId} = renderWithEverything(, {database}); + + // Component should render successfully with BoR config + const container = getByTestId('draft_input'); + expect(container).toBeVisible(); + + expect(queryByTestId('bor_label')).not.toBeVisible(); + }); + + it('passes BoR config to QuickActions component', () => { + const borConfig = { + enabled: true, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + } as PostBoRConfig; + + const props = { + ...baseProps, + postBoRConfig: borConfig, + }; + + const {getByTestId} = renderWithEverything(, {database}); + expect(getByTestId('bor_label')).toBeVisible(); + expect(getByTestId('bor_label')).toHaveTextContent('BURN ON READ (5m)'); + }); + + it('calls updatePostBoRStatus when BoR is toggled', async () => { + await operator.handleConfigs({ + configs: [ + {id: 'EnableBurnOnRead', value: 'true'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + {id: 'BurnOnReadDurationSeconds', value: '300'}, + {id: 'BurnOnReadMaximumTimeToLiveSeconds', value: '3600'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}], + prepareRecordsOnly: false, + }); + + const updatePostBoRStatusMock = jest.fn(); + const props = { + ...baseProps, + updatePostBoRStatus: updatePostBoRStatusMock, + }; + + const {getByTestId} = renderWithEverything(, {database}); + const borQuickAction = getByTestId('draft_input.quick_actions.bor_action'); + expect(borQuickAction).toBeVisible(); + + await act(async () => { + fireEvent.press(borQuickAction); + }); + + expect(updatePostBoRStatusMock).toHaveBeenCalledWith({ + enabled: true, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + }); + }); + + it('renders Header component with BoR config', () => { + const borConfig = { + enabled: true, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + } as PostBoRConfig; + + const props = { + ...baseProps, + postBoRConfig: borConfig, + }; + + const {getByTestId} = renderWithEverything(, {database}); + + // The Header component should receive the BoR config + const container = getByTestId('draft_input'); + expect(container).toBeVisible(); + }); + + it('maintains BoR config state during message sending', async () => { + const borConfig = { + enabled: true, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + } as PostBoRConfig; + + const props = { + ...baseProps, + postBoRConfig: borConfig, + value: 'test message', + }; + + const {getByTestId} = renderWithEverything(, {database}); + + // Send message + fireEvent.press(getByTestId('draft_input.send_action.send.button')); + + // Verify sendMessage was called + expect(baseProps.sendMessage).toHaveBeenCalled(); + + // BoR config should remain unchanged + expect(props.postBoRConfig?.enabled).toBe(true); + expect(props.postBoRConfig?.borDurationSeconds).toBe(300); + }); + }); }); diff --git a/app/components/post_draft/draft_input/draft_input.tsx b/app/components/post_draft/draft_input/draft_input.tsx index adaef77d9..e3209e075 100644 --- a/app/components/post_draft/draft_input/draft_input.tsx +++ b/app/components/post_draft/draft_input/draft_input.tsx @@ -24,6 +24,8 @@ import Uploads from '../uploads'; import Header from './header'; +import type {AvailableScreens} from '@typings/screens/navigation'; + export type Props = { testID?: string; channelId: string; @@ -32,10 +34,13 @@ export type Props = { rootId?: string; currentUserId: string; canShowPostPriority?: boolean; + location?: AvailableScreens; // Post Props postPriority: PostPriority; + postBoRConfig?: PostBoRConfig; updatePostPriority: (postPriority: PostPriority) => void; + updatePostBoRStatus: (config: PostBoRConfig) => void; persistentNotificationInterval: number; persistentNotificationMaxRecipients: number; @@ -128,10 +133,13 @@ function DraftInput({ updatePostInputTop, postPriority, updatePostPriority, + updatePostBoRStatus, persistentNotificationInterval, persistentNotificationMaxRecipients, setIsFocused, scheduledPostsEnabled, + postBoRConfig, + location, }: Props) { const intl = useIntl(); const serverUrl = useServerUrl(); @@ -216,6 +224,7 @@ function DraftInput({
({ @@ -22,6 +24,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ alignItems: 'center', marginLeft: 12, gap: 7, + flexWrap: 'wrap', }, error: { color: PostPriorityColors.URGENT, @@ -37,13 +40,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ export default function DraftInputHeader({ postPriority, noMentionsError, + postBoRConfig, }: Props) { const theme = useTheme(); const hasLabels = postPriority.priority !== '' || postPriority.requested_ack; + const hasBoR = postBoRConfig && postBoRConfig.enabled; const style = getStyleSheet(theme); return ( - + {postPriority.priority && ( )} @@ -83,6 +88,11 @@ export default function DraftInputHeader({ )} )} + {hasBoR && + + } ); } diff --git a/app/components/post_draft/post_draft.tsx b/app/components/post_draft/post_draft.tsx index a7bc99ce4..1dd66a830 100644 --- a/app/components/post_draft/post_draft.tsx +++ b/app/components/post_draft/post_draft.tsx @@ -100,6 +100,7 @@ function PostDraft({ updateValue={setValue} value={value} setIsFocused={setIsFocused} + location={location} /> ); diff --git a/app/components/post_draft/quick_actions/bor_quick_action/bor_quick_action.tsx b/app/components/post_draft/quick_actions/bor_quick_action/bor_quick_action.tsx new file mode 100644 index 000000000..4d0970fd4 --- /dev/null +++ b/app/components/post_draft/quick_actions/bor_quick_action/bor_quick_action.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; + +import CompassIcon from '@components/compass_icon'; +import PressableOpacity from '@components/pressable_opacity'; +import {ICON_SIZE} from '@constants/post_draft'; +import {useTheme} from '@context/theme'; +import {changeOpacity} from '@utils/theme'; + +import {style} from '../post_priority_action'; + +type Props = { + testId?: string; + postBoRConfig?: PostBoRConfig; + updatePostBoRStatus: (config: PostBoRConfig) => void; + defaultBorConfig: PostBoRConfig; +} + +export default function BoRQuickAction({testId, defaultBorConfig, postBoRConfig, updatePostBoRStatus}: Props) { + const theme = useTheme(); + const iconColor = changeOpacity(theme.centerChannelColor, 0.64); + + const toggleEnabled = useCallback(() => { + const config = {...(postBoRConfig || defaultBorConfig)}; + config.enabled = !config.enabled; + updatePostBoRStatus(config); + }, [defaultBorConfig, postBoRConfig, updatePostBoRStatus]); + + return ( + + + + ); +} diff --git a/app/components/post_draft/quick_actions/bor_quick_action/index.test.tsx b/app/components/post_draft/quick_actions/bor_quick_action/index.test.tsx new file mode 100644 index 000000000..a8c1def12 --- /dev/null +++ b/app/components/post_draft/quick_actions/bor_quick_action/index.test.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent} from '@testing-library/react-native'; +import React from 'react'; + +import {License} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import NetworkManager from '@managers/network_manager'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import BoRQuickAction from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +const SERVER_URL = 'https://appv1.mattermost.com'; + +// this is needed to when using the useServerUrl hook +jest.mock('@context/server', () => ({ + useServerUrl: jest.fn(() => SERVER_URL), +})); + +describe('BoRQuickAction', () => { + const baseProps = { + testId: 'bor_quick_action', + postBoRConfig: undefined, + updatePostBoRStatus: jest.fn(), + defaultBorConfig: { + enabled: false, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + } as PostBoRConfig, + }; + + let database: Database; + let operator: ServerDataOperator; + + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(SERVER_URL); + database = server.database; + operator = server.operator; + + await operator.handleConfigs({ + configs: [ + {id: 'EnableBurnOnRead', value: 'true'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + {id: 'BurnOnReadDurationSeconds', value: '300'}, + {id: 'BurnOnReadMaximumTimeToLiveSeconds', value: '3600'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}], + prepareRecordsOnly: false, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await TestHelper.tearDown(); + NetworkManager.invalidateClient(SERVER_URL); + }); + + it('renders the BoR quick action button', async () => { + const {getByTestId} = renderWithEverything(, {database}); + + const borButton = getByTestId('bor_quick_action'); + expect(borButton).toBeVisible(); + }); + + it('toggles BoR status from disabled to enabled when pressed', async () => { + const updatePostBoRStatusMock = jest.fn(); + const props = { + ...baseProps, + updatePostBoRStatus: updatePostBoRStatusMock, + postBoRConfig: { + enabled: false, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + } as PostBoRConfig, + }; + + const {getByTestId} = renderWithEverything(, {database}); + + const borButton = getByTestId('bor_quick_action'); + fireEvent.press(borButton); + + expect(updatePostBoRStatusMock).toHaveBeenCalledWith({ + enabled: true, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + }); + }); + + it('toggles BoR status from enabled to disabled when pressed', async () => { + const updatePostBoRStatusMock = jest.fn(); + const props = { + ...baseProps, + updatePostBoRStatus: updatePostBoRStatusMock, + postBoRConfig: { + enabled: true, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + } as PostBoRConfig, + }; + + const {getByTestId} = renderWithEverything(, {database}); + + const borButton = getByTestId('bor_quick_action'); + fireEvent.press(borButton); + + expect(updatePostBoRStatusMock).toHaveBeenCalledWith({ + enabled: false, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + }); + }); + + it('uses default config when postBoRConfig is undefined', async () => { + const updatePostBoRStatusMock = jest.fn(); + const props = { + ...baseProps, + updatePostBoRStatus: updatePostBoRStatusMock, + postBoRConfig: undefined, + }; + + const {getByTestId} = renderWithEverything(, {database}); + + const borButton = getByTestId('bor_quick_action'); + fireEvent.press(borButton); + + expect(updatePostBoRStatusMock).toHaveBeenCalledWith({ + enabled: true, + borDurationSeconds: 300, + borMaximumTimeToLiveSeconds: 3600, + }); + }); +}); diff --git a/app/components/post_draft/quick_actions/bor_quick_action/index.ts b/app/components/post_draft/quick_actions/bor_quick_action/index.ts new file mode 100644 index 000000000..c5b43aafc --- /dev/null +++ b/app/components/post_draft/quick_actions/bor_quick_action/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import {observeBoRConfig} from '@queries/servers/post'; + +import BoRAction from './bor_quick_action'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const defaultBorConfig = observeBoRConfig(database); + return { + defaultBorConfig, + }; +}); + +export default withDatabase(enhanced(BoRAction)); diff --git a/app/components/post_draft/quick_actions/index.ts b/app/components/post_draft/quick_actions/index.ts index 7ebfec66e..9292c3013 100644 --- a/app/components/post_draft/quick_actions/index.ts +++ b/app/components/post_draft/quick_actions/index.ts @@ -4,7 +4,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; -import {observeIsPostPriorityEnabled} from '@queries/servers/post'; +import {observeIsBoREnabled, observeIsPostPriorityEnabled} from '@queries/servers/post'; import {observeCanUploadFiles} from '@queries/servers/security'; import {observeMaxFileCount} from '@queries/servers/system'; @@ -19,6 +19,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { return { canUploadFiles, isPostPriorityEnabled: observeIsPostPriorityEnabled(database), + isBoREnabled: observeIsBoREnabled(database), maxFileCount, }; }); diff --git a/app/components/post_draft/quick_actions/post_priority_action/index.tsx b/app/components/post_draft/quick_actions/post_priority_action/index.tsx index e38912c95..a5db8d56c 100644 --- a/app/components/post_draft/quick_actions/post_priority_action/index.tsx +++ b/app/components/post_draft/quick_actions/post_priority_action/index.tsx @@ -22,7 +22,7 @@ type Props = { updatePostPriority: (postPriority: PostPriority) => void; } -const style = StyleSheet.create({ +export const style = StyleSheet.create({ icon: { alignItems: 'center', justifyContent: 'center', diff --git a/app/components/post_draft/quick_actions/quick_actions.test.tsx b/app/components/post_draft/quick_actions/quick_actions.test.tsx index 72a783d43..03842d36f 100644 --- a/app/components/post_draft/quick_actions/quick_actions.test.tsx +++ b/app/components/post_draft/quick_actions/quick_actions.test.tsx @@ -1,13 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Screens} from '@constants'; import {PostPriorityType} from '@constants/post'; import {renderWithIntlAndTheme} from '@test/intl-test-helper'; import QuickActions from './quick_actions'; +jest.mock('@components/post_draft/quick_actions/bor_quick_action', () => ({ + __esModule: true, + default: jest.fn(), +})); + describe('Quick Actions', () => { const baseProps: Parameters[0] = { + isBoREnabled: false, + updatePostBoRStatus: jest.fn(), testID: 'test-quick-actions', canUploadFiles: true, fileCount: 0, @@ -23,6 +31,7 @@ describe('Quick Actions', () => { }, updatePostPriority: jest.fn(), focus: jest.fn(), + location: Screens.CHANNEL, }; describe('slash commands', () => { @@ -170,4 +179,33 @@ describe('Quick Actions', () => { }); }); + describe('BoR quick action', () => { + it('should render BoR action when isBoREnabled is true', () => { + const props = { + ...baseProps, + isBoREnabled: true, + }; + const {queryByTestId} = renderWithIntlAndTheme(); + expect(queryByTestId('test-quick-actions.bor_action')).toBeDefined(); + }); + + it('should not render BoR action when isBoREnabled is false', () => { + const props = { + ...baseProps, + isBoREnabled: false, + }; + const {queryByTestId} = renderWithIntlAndTheme(); + expect(queryByTestId('test-quick-actions.bor_action')).toBeNull(); + }); + + it('should not render BoR action in threads', () => { + const props = { + ...baseProps, + isBoREnabled: true, + location: Screens.THREAD, + }; + const {queryByTestId} = renderWithIntlAndTheme(); + expect(queryByTestId('test-quick-actions.bor_action')).toBeNull(); + }); + }); }); diff --git a/app/components/post_draft/quick_actions/quick_actions.tsx b/app/components/post_draft/quick_actions/quick_actions.tsx index e289e3f95..3113dbe42 100644 --- a/app/components/post_draft/quick_actions/quick_actions.tsx +++ b/app/components/post_draft/quick_actions/quick_actions.tsx @@ -4,20 +4,27 @@ import React from 'react'; import {StyleSheet, View} from 'react-native'; +import BoRQuickAction from '@components/post_draft/quick_actions/bor_quick_action'; +import {Screens} from '@constants'; + import AttachmentAction from './attachment_quick_action'; import EmojiAction from './emoji_quick_action'; import InputAction from './input_quick_action'; import PostPriorityAction from './post_priority_action'; +import type {AvailableScreens} from '@typings/screens/navigation'; + type Props = { testID?: string; canUploadFiles: boolean; fileCount: number; isPostPriorityEnabled: boolean; + isBoREnabled: boolean; canShowPostPriority?: boolean; canShowSlashCommands?: boolean; canShowEmojiPicker?: boolean; maxFileCount: number; + location?: AvailableScreens; // Draft Handler value: string; @@ -25,6 +32,8 @@ type Props = { addFiles: (file: FileInfo[]) => void; postPriority: PostPriority; updatePostPriority: (postPriority: PostPriority) => void; + postBoRConfig?: PostBoRConfig; + updatePostBoRStatus?: (config: PostBoRConfig) => void; focus: () => void; } @@ -45,6 +54,7 @@ export default function QuickActions({ value, fileCount, isPostPriorityEnabled, + isBoREnabled, canShowSlashCommands = true, canShowPostPriority, canShowEmojiPicker = true, @@ -54,15 +64,20 @@ export default function QuickActions({ postPriority, updatePostPriority, focus, + updatePostBoRStatus, + postBoRConfig, + location, }: Props) { const atDisabled = value[value.length - 1] === '@'; const slashDisabled = value.length > 0; + const showBoRAction = isBoREnabled && updatePostBoRStatus && location === Screens.CHANNEL; const atInputActionTestID = `${testID}.at_input_action`; const slashInputActionTestID = `${testID}.slash_input_action`; const emojiActionTestID = `${testID}.emoji_action`; const attachmentActionTestID = `${testID}.attachment_action`; const postPriorityActionTestID = `${testID}.post_priority_action`; + const borPriorityActionTestID = `${testID}.bor_action`; const uploadProps = { disabled: !canUploadFiles, @@ -109,6 +124,13 @@ export default function QuickActions({ updatePostPriority={updatePostPriority} /> )} + {showBoRAction && + + } ); } diff --git a/app/components/post_draft/send_handler/index.ts b/app/components/post_draft/send_handler/index.ts index 5aca4ed24..2c6aa3291 100644 --- a/app/components/post_draft/send_handler/index.ts +++ b/app/components/post_draft/send_handler/index.ts @@ -2,8 +2,8 @@ // See LICENSE.txt for license information. import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {combineLatest, of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {combineLatest, type Observable, of as of$, shareReplay} from 'rxjs'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {General, Permissions} from '@constants'; import {DRAFT_TYPE_SCHEDULED, type DraftType} from '@constants/draft'; @@ -40,31 +40,34 @@ const enhanced = withObservables(['channelId', 'rootId', 'draftType'], (ownProps switchMap((u) => of$(u?.status === General.OUT_OF_OFFICE)), ); - let postPriority; + let metadata: Observable; if (draftType === DRAFT_TYPE_SCHEDULED) { - postPriority = queryScheduledPost(database, channelId, rootId).observeWithColumns(['metadata']).pipe( - switchMap(observeFirstScheduledPost), - switchMap((d) => { - if (!d?.metadata?.priority) { - return of$(INITIAL_PRIORITY); - } - - return of$(d.metadata.priority); - }), - ); + metadata = queryScheduledPost(database, channelId, rootId).observeWithColumns(['metadata']). + pipe( + switchMap(observeFirstScheduledPost), + switchMap((item) => of$(item?.metadata || {})), + shareReplay({bufferSize: 1, refCount: true}), + ); } else { - postPriority = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe( + metadata = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe( switchMap(observeFirstDraft), - switchMap((d) => { - if (!d?.metadata?.priority) { - return of$(INITIAL_PRIORITY); - } - - return of$(d.metadata.priority); - }), + switchMap((item) => of$(item?.metadata || {})), + shareReplay({bufferSize: 1, refCount: true}), ); } + const postPriority = metadata.pipe( + switchMap((m) => of$(m?.priority || INITIAL_PRIORITY)), + distinctUntilChanged(), + ); + + const postBoRConfig = metadata.pipe( + switchMap((m) => { + return of$(m?.borConfig || null); + }), + distinctUntilChanged(), + ); + const enableConfirmNotificationsToChannel = observeConfigBooleanValue(database, 'EnableConfirmNotificationsToChannel'); const maxMessageLength = observeConfigIntValue(database, 'MaxPostSize', MAX_MESSAGE_LENGTH_FALLBACK); const persistentNotificationInterval = observeConfigIntValue(database, 'PersistentNotificationIntervalMinutes'); @@ -104,6 +107,7 @@ const enhanced = withObservables(['channelId', 'rootId', 'draftType'], (ownProps persistentNotificationInterval, persistentNotificationMaxRecipients, postPriority, + postBoRConfig, }; }); diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx index a311a1eba..edf4e5e4a 100644 --- a/app/components/post_draft/send_handler/send_handler.tsx +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -3,7 +3,7 @@ import React, {useCallback} from 'react'; -import {updateDraftPriority} from '@actions/local/draft'; +import {updateDraftBoRConfig, updateDraftPriority} from '@actions/local/draft'; import SendDraft from '@components/draft_scheduled_post/draft_scheduled_post_actions/send_draft'; import DraftInput from '@components/post_draft/draft_input/'; import {PostPriorityType} from '@constants/post'; @@ -45,6 +45,7 @@ type Props = { persistentNotificationInterval: number; persistentNotificationMaxRecipients: number; postPriority: PostPriority; + postBoRConfig?: PostBoRConfig; draftType?: DraftType; postId?: string; @@ -52,6 +53,7 @@ type Props = { channelDisplayName?: string; isFromDraftView?: boolean; draftReceiverUserName?: string; + location?: AvailableScreens; } export const INITIAL_PRIORITY = { @@ -93,6 +95,8 @@ export default function SendHandler({ isFromDraftView, draftType, postId, + postBoRConfig, + location, }: Props) { const serverUrl = useServerUrl(); @@ -100,6 +104,10 @@ export default function SendHandler({ updateDraftPriority(serverUrl, channelId, rootId, priority); }, [serverUrl, channelId, rootId]); + const handlePostBoRStatus = useCallback((config: PostBoRConfig) => { + updateDraftBoRConfig(serverUrl, channelId, rootId, config); + }, [channelId, rootId, serverUrl]); + const {handleSendMessage, canSend} = useHandleSendMessage({ value, channelId, @@ -115,6 +123,7 @@ export default function SendHandler({ channelType, postPriority, clearDraft, + postBoRConfig, }); if (isFromDraftView) { @@ -166,10 +175,13 @@ export default function SendHandler({ maxMessageLength={maxMessageLength} updatePostInputTop={updatePostInputTop} postPriority={postPriority} + postBoRConfig={postBoRConfig} updatePostPriority={handlePostPriority} + updatePostBoRStatus={handlePostBoRStatus} persistentNotificationInterval={persistentNotificationInterval} persistentNotificationMaxRecipients={persistentNotificationMaxRecipients} setIsFocused={setIsFocused} + location={location} /> ); } diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index 8602e1209..d8ea0179e 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -14,7 +14,7 @@ import {CHANNEL, THREAD} from '@constants/screens'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {DEFAULT_LOCALE} from '@i18n'; -import {isOwnBoRPost, isUnrevealedBoRPost} from '@utils/bor'; +import {isUnrevealedBoRPost} from '@utils/bor'; import {postUserDisplayName} from '@utils/post'; import {makeStyleSheetFromTheme} from '@utils/theme'; import {ensureString} from '@utils/types'; @@ -101,9 +101,7 @@ const Header = (props: HeaderProps) => { const userIconOverride = ensureString(post.props?.override_icon_url); const usernameOverride = ensureString(post.props?.override_username); - const isUnrevealedPost = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]); - const ownBoRPost = useMemo(() => isOwnBoRPost(post, currentUser?.id), [currentUser?.id, post]); - const showBoRIcon = isUnrevealedPost || ownBoRPost; + const showBoRIcon = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]); const borExpireAt = post.metadata?.expire_at; const serverUrl = useServerUrl(); @@ -164,7 +162,7 @@ const Header = (props: HeaderProps) => { /> } { - !showBoRIcon && Boolean(borExpireAt) && + Boolean(borExpireAt) && { + if (isBoRPost(post)) { + return; + } + pressDetected.current = true; KeyboardController.dismiss(); diff --git a/app/components/pressable_opacity/index.tsx b/app/components/pressable_opacity/index.tsx index 5aea461db..80d68cfb1 100644 --- a/app/components/pressable_opacity/index.tsx +++ b/app/components/pressable_opacity/index.tsx @@ -3,7 +3,7 @@ import React, {useCallback} from 'react'; import {Pressable} from 'react-native-gesture-handler'; -import {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -27,7 +27,7 @@ export default function PressableOpacity({children, onPress, style}: Props) { }, []); const cancelPressOut = useCallback(() => { - cancelOpacity.value = withTiming(1, {duration: 100}); + cancelOpacity.value = withTiming(1, {duration: 300}); }, []); return ( @@ -35,9 +35,11 @@ export default function PressableOpacity({children, onPress, style}: Props) { onPressIn={cancelPressIn} onPressOut={cancelPressOut} onPress={onPress} - style={[style, cancelAnimatedStyle]} + style={[style]} > - {children} + + {children} + ); } diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 000cca160..b5c0a2f9f 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -112,6 +112,7 @@ const WebsocketEvents = { // Burn on Read BOR_POST_REVEALED: 'post_revealed', BOR_POST_BURNED: 'post_burned', + BURN_ON_READ_ALL_REVEALED: 'burn_on_read_all_revealed', }; export default WebsocketEvents; diff --git a/app/database/operator/server_data_operator/handlers/post.test.ts b/app/database/operator/server_data_operator/handlers/post.test.ts index cc152f48d..2786713fe 100644 --- a/app/database/operator/server_data_operator/handlers/post.test.ts +++ b/app/database/operator/server_data_operator/handlers/post.test.ts @@ -18,6 +18,7 @@ import {shouldUpdateScheduledPostRecord} from '../comparators/scheduled_post'; import {exportedForTest} from './post'; import type ServerDataOperator from '@database/operator/server_data_operator/index'; +import type PostModel from '@typings/database/models/servers/post'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post'; @@ -813,6 +814,247 @@ describe('*** Operator: Post Handlers tests ***', () => { }), ); }); + + it('=> HandlePosts: should update burn-on-read post when its read receipts change', async () => { + const spyOnProcessRecords = jest.spyOn(operator, 'processRecords'); + + const now = Date.now(); + + // Create an unrevealed burn-on-read post + const existingPost: Post = { + id: 'bor_post_id', + create_at: 1596032651747, + update_at: 1596032651747, + edit_at: 0, + delete_at: 0, + is_pinned: false, + is_following: false, + user_id: 'user_id', + channel_id: 'channel_id', + root_id: '', + original_id: '', + message: 'This is the revealed message', + type: 'burn_on_read', + props: {expire_at: now + 1000000}, + hashtags: '', + pending_post_id: '', + reply_count: 0, + last_reply_at: 0, + participants: null, + metadata: {expire_at: now + 1000000}, + }; + + // First, create the existing post + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [existingPost.id], + posts: [existingPost], + prepareRecordsOnly: false, + }); + + // Now create an updated version of the same post + const updatedPost: Post = { + ...existingPost, + metadata: { + ...existingPost.metadata, + recipients: ['receipt_id_1'], + }, + }; + + // Handle the updated post + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [updatedPost.id], + posts: [updatedPost], + prepareRecordsOnly: false, + }); + + // Verify that processRecords was called with the shouldUpdate function that handles BoR posts + expect(spyOnProcessRecords).toHaveBeenCalledWith( + expect.objectContaining({ + createOrUpdateRawValues: [updatedPost], + shouldUpdate: expect.any(Function), + }), + ); + }); + + it('=> HandlePosts: should update burn-on-read post when its read by all', async () => { + const spyOnProcessRecords = jest.spyOn(operator, 'processRecords'); + const now = Date.now(); + + const existingPost: Post = { + id: 'bor_post_id', + create_at: 1596032651747, + update_at: 1596032651747, + edit_at: 0, + delete_at: 0, + is_pinned: false, + is_following: false, + user_id: 'user_id', + channel_id: 'channel_id', + root_id: '', + original_id: '', + message: 'This is the revealed message', + type: 'burn_on_read', + props: {expire_at: now + 1000000}, + hashtags: '', + pending_post_id: '', + reply_count: 0, + last_reply_at: 0, + participants: null, + metadata: {}, + }; + + // First, create the existing post + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [existingPost.id], + posts: [existingPost], + prepareRecordsOnly: false, + }); + + // Now create an updated version of the same post + const updatedPost: Post = { + ...existingPost, + metadata: { + ...existingPost.metadata, + expire_at: now + 1000000, + }, + }; + + // Handle the updated post + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [updatedPost.id], + posts: [updatedPost], + prepareRecordsOnly: false, + }); + + // Verify that processRecords was called with the shouldUpdate function that handles BoR posts + expect(spyOnProcessRecords).toHaveBeenCalledWith( + expect.objectContaining({ + createOrUpdateRawValues: [updatedPost], + shouldUpdate: expect.any(Function), + }), + ); + }); +}); + +describe('*** Operator: shouldUpdateForBoRPost tests ***', () => { + const {shouldUpdateForBoRPost} = exportedForTest; + + it('should return false for non-BoR posts', () => { + const existingPost = { + type: 'regular', + metadata: {}, + } as unknown as PostModel; + + const newPost = { + type: 'regular', + metadata: {}, + } as unknown as Post; + + expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(false); + }); + + it('should return false when only one post is BoR', () => { + const existingPost = { + type: 'burn_on_read', + metadata: {}, + } as PostModel; + + const newPost = { + type: 'regular', + metadata: {}, + } as unknown as Post; + + expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(false); + }); + + it('should return true when BoR post gets revealed', () => { + const existingPost = { + type: 'burn_on_read', + props: {expire_at: 123456789}, + metadata: {}, + } as unknown as PostModel; + + const newPost = { + type: 'burn_on_read', + metadata: {expire_at: 123456789}, + } as Post; + + expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true); + }); + + it('should return true when BoR post recipients list changes', () => { + const existingPost = { + type: 'burn_on_read', + metadata: {recipients: ['user1']}, + } as PostModel; + + const newPost = { + type: 'burn_on_read', + metadata: {recipients: ['user1', 'user2']}, + } as Post; + + expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true); + }); + + it('should return true when BoR post gets read by all (expire_at added)', () => { + const existingPost = { + type: 'burn_on_read', + metadata: {}, + } as PostModel; + + const newPost = { + type: 'burn_on_read', + metadata: {expire_at: 123456789}, + } as Post; + + expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true); + }); + + it('should return false when BoR posts have no relevant changes', () => { + const existingPost = { + type: 'burn_on_read', + metadata: {recipients: ['user1'], expire_at: 123456789}, + } as PostModel; + + const newPost = { + type: 'burn_on_read', + metadata: {recipients: ['user1'], expire_at: 123456789}, + } as Post; + + expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(false); + }); + + it('should handle missing recipients arrays', () => { + const existingPost = { + type: 'burn_on_read', + metadata: {}, + } as PostModel; + + const newPost = { + type: 'burn_on_read', + metadata: {recipients: ['user1']}, + } as Post; + + expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true); + }); + + it('should handle recipients array becoming empty', () => { + const existingPost = { + type: 'burn_on_read', + metadata: {recipients: ['user1']}, + } as PostModel; + + const newPost = { + type: 'burn_on_read', + metadata: {recipients: []}, + } as unknown as Post; + + expect(shouldUpdateForBoRPost(existingPost, newPost)).toBe(true); + }); }); describe('*** Operator: merge chunks ***', () => { diff --git a/app/database/operator/server_data_operator/handlers/post.ts b/app/database/operator/server_data_operator/handlers/post.ts index 4dff4583c..413c44b2d 100644 --- a/app/database/operator/server_data_operator/handlers/post.ts +++ b/app/database/operator/server_data_operator/handlers/post.ts @@ -112,8 +112,23 @@ const mergePostInChannelChunks = async (newChunk: PostsInChannelModel, existingC export const exportedForTest = { mergePostInChannelChunks, + shouldUpdateForBoRPost, }; +function shouldUpdateForBoRPost(e: PostModel, n: Post): boolean { + const bothBoRPost = e.type === PostTypes.BURN_ON_READ && n.type === PostTypes.BURN_ON_READ; + if (!bothBoRPost) { + return false; + } + + const borPostGotRevealed = isUnrevealedBoRPost(e) && !isUnrevealedBoRPost(n); + const borPostGotReadByAll = e.metadata?.expire_at === undefined && n.metadata?.expire_at !== undefined; + + // Since a user can't un-see a BoR post, we consider an update if the recipients list length has changed + const borRecipientsUpdated = (e.metadata?.recipients || []).length !== (n.metadata?.recipients || []).length; + return borPostGotRevealed || borRecipientsUpdated || borPostGotReadByAll; +} + const PostHandler = >(superclass: TBase) => class extends superclass { /** * handleScheduledPosts: Handler responsible for the Create/Update operations occurring the SchedulePost table from the 'Server' schema @@ -367,8 +382,7 @@ const PostHandler = >(supercla tableName, fieldName: 'id', shouldUpdate: (e: PostModel, n: Post) => { - const bothBoRPost = e.type === PostTypes.BURN_ON_READ && n.type === PostTypes.BURN_ON_READ; - if (bothBoRPost && isUnrevealedBoRPost(e) && !isUnrevealedBoRPost(n)) { + if (shouldUpdateForBoRPost(e, n)) { return true; } diff --git a/app/database/operator/server_data_operator/transformers/post.ts b/app/database/operator/server_data_operator/transformers/post.ts index f731889c4..0ecd6b4e7 100644 --- a/app/database/operator/server_data_operator/transformers/post.ts +++ b/app/database/operator/server_data_operator/transformers/post.ts @@ -121,6 +121,7 @@ export const transformDraftRecord = ({action, database, value}: TransformerArgs< draft.files = raw?.files ?? emptyFileInfo; draft.metadata = raw?.metadata ?? emptyPostMetadata; draft.updateAt = raw.update_at ?? Date.now(); + draft.type = raw.type ?? ''; }; return prepareBaseRecord({ diff --git a/app/hooks/handle_send_message.test.tsx b/app/hooks/handle_send_message.test.tsx index ea74b5916..655b150e1 100644 --- a/app/hooks/handle_send_message.test.tsx +++ b/app/hooks/handle_send_message.test.tsx @@ -657,4 +657,35 @@ describe('useHandleSendMessage', () => { expect(defaultProps.clearDraft).not.toHaveBeenCalled(); }); }); + + it('should handle sending BoR message', async () => { + const props = { + ...defaultProps, + postBoRConfig: { + enabled: true, + borDurationSeconds: 60, + borMaximumTimeToLiveSeconds: 3600, + } as PostBoRConfig, + }; + const {result} = renderHook(() => useHandleSendMessage(props), {wrapper}); + + expect(result.current.canSend).toBe(true); + + await act(async () => { + result.current.handleSendMessage(); + }); + + expect(createPost).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + channel_id: 'channel-id', + message: 'test message', + user_id: 'current-user', + type: 'burn_on_read', + }), + [], + ); + expect(defaultProps.clearDraft).toHaveBeenCalled(); + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_LIST_SCROLL_TO_BOTTOM, Screens.CHANNEL); + }); }); diff --git a/app/hooks/handle_send_message.ts b/app/hooks/handle_send_message.ts index 28e201e6e..5676a4813 100644 --- a/app/hooks/handle_send_message.ts +++ b/app/hooks/handle_send_message.ts @@ -53,6 +53,7 @@ type Props = { channelIsArchived?: boolean; channelIsReadOnly?: boolean; deactivatedChannel?: boolean; + postBoRConfig?: PostBoRConfig; } export const useHandleSendMessage = ({ @@ -75,6 +76,7 @@ export const useHandleSendMessage = ({ channelIsReadOnly, deactivatedChannel, clearDraft, + postBoRConfig, }: Props) => { const intl = useIntl(); const serverUrl = useServerUrl(); @@ -125,6 +127,10 @@ export const useHandleSendMessage = ({ }; } + if (postBoRConfig?.enabled) { + post.type = 'burn_on_read'; + } + let response: CreateResponse; if (schedulingInfo) { response = await createScheduledPost(serverUrl, scheduledPostFromPost(post, schedulingInfo, postPriority, postFiles)); @@ -165,7 +171,7 @@ export const useHandleSendMessage = ({ setSendingMessage(false); DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL); - }, [files, currentUserId, channelId, rootId, value, postPriority, isFromDraftView, serverUrl, intl, canPost, channelIsArchived, channelIsReadOnly, deactivatedChannel, clearDraft]); + }, [files, currentUserId, channelId, rootId, value, postPriority, postBoRConfig?.enabled, isFromDraftView, serverUrl, clearDraft, intl, canPost, channelIsArchived, channelIsReadOnly, deactivatedChannel]); const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean, schedulingInfo?: SchedulingInfo) => { const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, channelTimezoneCount, atHere); diff --git a/app/queries/servers/post.test.ts b/app/queries/servers/post.test.ts index 297a76579..d6b2524c6 100644 --- a/app/queries/servers/post.test.ts +++ b/app/queries/servers/post.test.ts @@ -2,13 +2,15 @@ // See LICENSE.txt for license information. import {Database} from '@nozbe/watermelondb'; +import {firstValueFrom} from 'rxjs'; -import {ActionType} from '@constants'; +import {ActionType, License} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import ServerDataOperator from '@database/operator/server_data_operator'; import TestHelper from '@test/test_helper'; -import {queryPostsWithPermalinkReferences} from './post'; +import {queryPostsWithPermalinkReferences, observeIsBoREnabled} from './post'; describe('Post Queries', () => { const serverUrl = 'post.test.com'; @@ -278,4 +280,91 @@ describe('Post Queries', () => { expect(result).toHaveLength(0); }); }); + + describe('observeIsBoREnabled', () => { + it('should return true when both feature is enabled and license is valid', async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}], + prepareRecordsOnly: false, + }); + + await operator.handleConfigs({ + configs: [ + {id: 'EnableBurnOnRead', value: 'true'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = await firstValueFrom(observeIsBoREnabled(database)); + expect(result).toBe(true); + }); + + it('should return false when feature is disabled but license is valid', async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}], + prepareRecordsOnly: false, + }); + + await operator.handleConfigs({ + configs: [ + {id: 'EnableBurnOnRead', value: 'false'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = await firstValueFrom(observeIsBoREnabled(database)); + expect(result).toBe(false); + }); + + it('should return false when feature is enabled but license is insufficient', async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Enterprise}}], + prepareRecordsOnly: false, + }); + + await operator.handleConfigs({ + configs: [ + {id: 'EnableBurnOnRead', value: 'true'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = await firstValueFrom(observeIsBoREnabled(database)); + expect(result).toBe(false); + }); + + it('should return false when both feature is disabled and license is insufficient', async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Professional}}], + prepareRecordsOnly: false, + }); + + await operator.handleConfigs({ + configs: [ + {id: 'EnableBurnOnRead', value: 'false'}, + {id: 'BuildEnterpriseReady', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const result = await firstValueFrom(observeIsBoREnabled(database)); + expect(result).toBe(false); + }); + + it('should return false when no config is set', async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced}}], + prepareRecordsOnly: false, + }); + + const result = await firstValueFrom(observeIsBoREnabled(database)); + expect(result).toBe(false); + }); + }); }); diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index d3381c2d5..afe6fe286 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -2,16 +2,17 @@ // See LICENSE.txt for license information. import {Database, Model, Q, Query} from '@nozbe/watermelondb'; -import {of as of$, combineLatestWith} from 'rxjs'; +import {of as of$, combineLatestWith, combineLatest} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {MM_TABLES} from '@constants/database'; +import {SKU_SHORT_NAME} from '@constants/license'; import {logDebug, logWarning} from '@utils/log'; import {updatePermalinkMetadata} from '@utils/permalink_sync'; import {queryGroupsByNames} from './group'; import {querySavedPostsPreferences} from './preference'; -import {getConfigValue, observeConfigBooleanValue} from './system'; +import {getConfigValue, observeConfigBooleanValue, observeConfigIntValue, observeIsMinimumLicenseTier} from './system'; import {queryUsersByUsername, observeUser, observeCurrentUser} from './user'; import type PostModel from '@typings/database/models/servers/post'; @@ -20,6 +21,9 @@ import type PostsInThreadModel from '@typings/database/models/servers/posts_in_t const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES; +const DEFAULT_BURN_ON_READ_DURATION_SECONDS = '600'; +const DEFAULT_BURN_ON_READ_MAXIMUM_TTL_SECONDS = '604800'; + export const prepareDeletePost = async (post: PostModel): Promise => { const preparedModels: Model[] = [post.prepareDestroyPermanently()]; const relations: Array> = [post.drafts, post.files, post.reactions]; @@ -254,6 +258,32 @@ export const observeIsPostPriorityEnabled = (database: Database) => { return observeConfigBooleanValue(database, 'PostPriority'); }; +export const observeIsBoREnabled = (database: Database) => { + const featureEnabled = observeConfigBooleanValue(database, 'EnableBurnOnRead'); + const licenseValid = observeIsMinimumLicenseTier(database, SKU_SHORT_NAME.EnterpriseAdvanced); + + return combineLatest([featureEnabled, licenseValid]).pipe( + switchMap(([enabled, licensed]) => of$(enabled && licensed)), + ); +}; + +export const observeBoRConfig = (database: Database) => { + const borDurationSecondsObservable = observeConfigIntValue(database, 'BurnOnReadDurationSeconds'); + const maxBoRDurationSecondsStringObservable = observeConfigIntValue(database, 'BurnOnReadMaximumTimeToLiveSeconds'); + + // merge all observables and return single observable of object with both values + return combineLatest([borDurationSecondsObservable, maxBoRDurationSecondsStringObservable]).pipe( + switchMap(([borDurationSeconds, borMaximumTimeToLiveSeconds]) => { + const borConfig = { + enabled: false, + borDurationSeconds: borDurationSeconds || parseInt(DEFAULT_BURN_ON_READ_DURATION_SECONDS, 10), + borMaximumTimeToLiveSeconds: borMaximumTimeToLiveSeconds || parseInt(DEFAULT_BURN_ON_READ_MAXIMUM_TTL_SECONDS, 10), + }; + return of$(borConfig); + }), + ); +}; + export const observeIsPostAcknowledgementsEnabled = (database: Database) => { return observeConfigBooleanValue(database, 'PostAcknowledgements'); }; diff --git a/app/screens/post_options/bor_read_receipts/index.test.tsx b/app/screens/post_options/bor_read_receipts/index.test.tsx new file mode 100644 index 000000000..5aacf6d1e --- /dev/null +++ b/app/screens/post_options/bor_read_receipts/index.test.tsx @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react-native'; + +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import BORReadReceipts from './index'; + +import type {Database} from '@nozbe/watermelondb'; + +describe('BORReadReceipts', () => { + let database: Database; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + it('should render title and read receipt text with correct values', () => { + renderWithEverything( + , + {database}, + ); + + expect(screen.getByText('Burn-on-read message')).toBeVisible(); + expect(screen.getByText('Read by 3 of 5 recipients')).toBeVisible(); + }); + + it('should render with zero read receipts', () => { + renderWithEverything( + , + {database}, + ); + + expect(screen.getByText('Burn-on-read message')).toBeVisible(); + expect(screen.getByText('Read by 0 of 10 recipients')).toBeVisible(); + }); + + it('should render when all recipients have read', () => { + renderWithEverything( + , + {database}, + ); + + expect(screen.getByText('Burn-on-read message')).toBeVisible(); + expect(screen.getByText('Read by 7 of 7 recipients')).toBeVisible(); + }); +}); diff --git a/app/screens/post_options/bor_read_receipts/index.tsx b/app/screens/post_options/bor_read_receipts/index.tsx new file mode 100644 index 000000000..707c36c73 --- /dev/null +++ b/app/screens/post_options/bor_read_receipts/index.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +export const BOR_READ_RECEIPTS_HEIGHT = 54; + +type Props = { + totalReceipts: number; + readReceipts: number; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + borderBottomWidth: 1, + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.16), + paddingBottom: 16, + marginBottom: 16, + display: 'flex', + flexDirection: 'column', + gap: 2, + height: BOR_READ_RECEIPTS_HEIGHT, + }, + title: { + ...typography('Body', 50, 'SemiBold'), + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + body: { + color: theme.centerChannelColor, + }, + }; +}); + +export default function BORReadReceipts({totalReceipts, readReceipts}: Props) { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + + + + + ); +} diff --git a/app/screens/post_options/index.test.tsx b/app/screens/post_options/index.test.tsx index 9b6b228f3..51667f198 100644 --- a/app/screens/post_options/index.test.tsx +++ b/app/screens/post_options/index.test.tsx @@ -164,4 +164,62 @@ describe('PostOptions', () => { expect(screen.queryByText('Mark as Unread')).not.toBeVisible(); expect(screen.queryByText('Follow Message')).not.toBeVisible(); }); + + it('should show BOR read receipts for own BoR post', async () => { + const ownBoRPost = TestHelper.fakePostModel({ + type: PostTypes.BURN_ON_READ, + channelId: TestHelper.basicChannel!.id, + userId: TestHelper.basicUser!.id, + message: 'This is my BoR post', + metadata: { + expire_at: Date.now() + 1000000, + recipients: ['user1', 'user2'], + }, + }); + + renderWithEverything( + , + {database}, + ); + + await waitFor(() => { + expect(screen.queryByTestId('bor_read_receipts')).toBeVisible(); + }); + + expect(screen.getByText('Read by 2 of 99 recipients')).toBeVisible(); + }); + + it('should not show BOR read receipts for other users BoR post', async () => { + const otherUserBoRPost = TestHelper.fakePostModel({ + type: PostTypes.BURN_ON_READ, + channelId: TestHelper.basicChannel!.id, + userId: TestHelper.generateId(), // Different user + message: 'This is someone else\'s BoR post', + metadata: { + expire_at: Date.now() + 1000000, + recipients: ['user1', 'user2'], + }, + }); + + renderWithEverything( + , + {database}, + ); + + await waitFor(() => { + expect(screen.queryByTestId('bor_read_receipts')).not.toBeVisible(); + }); + }); }); diff --git a/app/screens/post_options/index.ts b/app/screens/post_options/index.ts index 8d270fb74..de94e297a 100644 --- a/app/screens/post_options/index.ts +++ b/app/screens/post_options/index.ts @@ -3,20 +3,20 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of as of$, Observable} from 'rxjs'; -import {distinctUntilChanged, switchMap} from 'rxjs/operators'; +import {switchMap} from 'rxjs/operators'; import {Permissions, Post, Screens} from '@constants'; import {AppBindingLocations} from '@constants/apps'; import {MAX_ALLOWED_REACTIONS} from '@constants/emoji'; import AppsManager from '@managers/apps_manager'; -import {observeChannel, observeIsReadOnlyChannel} from '@queries/servers/channel'; +import {observeChannel, observeChannelInfo, observeIsReadOnlyChannel} from '@queries/servers/channel'; import {observePost, observePostSaved} from '@queries/servers/post'; import {observeReactionsForPost} from '@queries/servers/reaction'; import {observePermissionForChannel, observePermissionForPost} from '@queries/servers/role'; import {observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system'; import {observeIsCRTEnabled, observeThreadById} from '@queries/servers/thread'; import {observeCurrentUser} from '@queries/servers/user'; -import {isBoRPost, isUnrevealedBoRPost} from '@utils/bor'; +import {isBoRPost, isOwnBoRPost, isUnrevealedBoRPost} from '@utils/bor'; import {toMilliseconds} from '@utils/datetime'; import {isMinimumServerVersion} from '@utils/helpers'; import {isFromWebhook, isSystemMessage} from '@utils/post'; @@ -77,6 +77,7 @@ const withPost = withObservables([], ({post, database}: {post: Post | PostModel} const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sourceScreen, database, serverUrl}: EnhancedProps) => { const channel = observeChannel(database, post.channelId); + const channelInfo = observeChannelInfo(database, post.channelId); const channelIsArchived = channel.pipe(switchMap((ch: ChannelModel) => of$(ch.deleteAt !== 0))); const currentUser = observeCurrentUser(database); const isLicensed = observeLicense(database).pipe(switchMap((lcs) => of$(lcs?.IsLicensed === 'true'))); @@ -85,7 +86,6 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour const postEditTimeLimit = observeConfigIntValue(database, 'PostEditTimeLimit', -1); const bindings = AppsManager.observeBindings(serverUrl, AppBindingLocations.POST_MENU_ITEM); const borPost = isBoRPost(post); - const unrevealedBoRPost = isUnrevealedBoRPost(post); const canPostPermission = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => observePermissionForChannel(database, c, u, Permissions.CREATE_POST, false))); const hasAddReactionPermission = currentUser.pipe(switchMap((u) => observePermissionForPost(database, post, u, Permissions.ADD_REACTION, true))); @@ -140,9 +140,11 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour )), ); - const canAddReaction = unrevealedBoRPost ? of$(false) : combineLatest([hasAddReactionPermission, channelIsReadOnly, isUnderMaxAllowedReactions, channelIsArchived]).pipe( - switchMap(([permission, readOnly, maxAllowed, isArchived]) => { - return of$(!isSystemMessage(post) && permission && !readOnly && !isArchived && maxAllowed && showAddReaction); + const canAddReaction = combineLatest([hasAddReactionPermission, channelIsReadOnly, isUnderMaxAllowedReactions, channelIsArchived, currentUser]).pipe( + switchMap(([permission, readOnly, maxAllowed, isArchived, user]) => { + // Can't react on unrevealed BoR posts of other users + const preventBoRReaction = isUnrevealedBoRPost(post) && post.userId !== user?.id; + return of$(!isSystemMessage(post) && permission && !readOnly && !isArchived && maxAllowed && showAddReaction && !preventBoRReaction); }), ); @@ -155,9 +157,22 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour switchMap((enabled) => (enabled ? observeThreadById(database, post.id) : of$(undefined))), ); - const currentUserId = currentUser.pipe( - switchMap((u) => of$(u?.id)), - distinctUntilChanged(), + const showBoRReadReceipts = combineLatest([currentUser]).pipe( + switchMap(([user]) => { + return of$(isOwnBoRPost(post, user?.id)); + }), + ); + + const borReceiptData = combineLatest([channelInfo]).pipe( + switchMap(([info]) => { + const revealedCount = post.metadata?.recipients?.length || 0; + const totalRecipients = info ? Math.max(0, info.memberCount - 1) : 0; + + return of$({ + revealedCount, + totalRecipients, + }); + }), ); return { @@ -173,7 +188,9 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sour thread, bindings, isBoRPost: of$(borPost), - currentUserId, + showBoRReadReceipts, + borReceiptData, + currentUser, }; }); diff --git a/app/screens/post_options/options/delete_post_option.test.tsx b/app/screens/post_options/options/delete_post_option.test.tsx new file mode 100644 index 000000000..08fb2bc23 --- /dev/null +++ b/app/screens/post_options/options/delete_post_option.test.tsx @@ -0,0 +1,270 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, screen} from '@testing-library/react-native'; +import {Alert} from 'react-native'; + +import {deletePost, burnPostNow} from '@actions/remote/post'; +import {dismissBottomSheet} from '@screens/navigation'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; +import {isBoRPost, isOwnBoRPost} from '@utils/bor'; + +import DeletePostOption from './delete_post_option'; + +import type {Database} from '@nozbe/watermelondb'; +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; + +// Mock the dependencies +jest.mock('@actions/remote/post'); +jest.mock('@screens/navigation'); +jest.mock('@utils/bor'); + +const mockDeletePost = deletePost as jest.MockedFunction; +const mockBurnPostNow = burnPostNow as jest.MockedFunction; +const mockDismissBottomSheet = dismissBottomSheet as jest.MockedFunction; +const mockIsBoRPost = isBoRPost as jest.MockedFunction; +const mockIsOwnBoRPost = isOwnBoRPost as jest.MockedFunction; +const mockAlert = Alert.alert as jest.MockedFunction; + +describe('DeletePostOption', () => { + let database: Database; + let mockPost: PostModel; + let mockCurrentUser: UserModel; + + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockPost = { + id: 'post-id-1', + userId: 'user-id-1', + } as PostModel; + + mockCurrentUser = { + id: 'user-id-1', + } as UserModel; + + mockDismissBottomSheet.mockResolvedValue(); + mockDeletePost.mockResolvedValue({post: {post: mockPost}}); + mockBurnPostNow.mockResolvedValue({post: {post: mockPost}}); + }); + + const getDefaultProps = () => ({ + bottomSheetId: 'PostOptions' as const, + post: mockPost, + currentUser: mockCurrentUser, + }); + + it('should render delete option with correct text and icon', () => { + mockIsBoRPost.mockReturnValue(false); + + renderWithEverything( + , + {database}, + ); + + expect(screen.getByText('Delete')).toBeVisible(); + expect(screen.getByTestId('post_options.delete_post.option')).toBeVisible(); + }); + + describe('Regular Post Deletion', () => { + beforeEach(() => { + mockIsBoRPost.mockReturnValue(false); + }); + + it('should show confirmation alert when pressed for regular post', () => { + renderWithEverything( + , + {database}, + ); + + const deleteOption = screen.getByTestId('post_options.delete_post.option'); + fireEvent.press(deleteOption); + + expect(mockAlert).toHaveBeenCalledWith( + 'Delete Post', + 'Are you sure you want to delete this post?', + expect.arrayContaining([ + expect.objectContaining({ + text: 'Cancel', + style: 'cancel', + }), + expect.objectContaining({ + text: 'Delete', + style: 'destructive', + onPress: expect.any(Function), + }), + ]), + ); + }); + + it('should call deletePost when confirmed for regular post', async () => { + renderWithEverything( + , + {database}, + ); + + const deleteOption = screen.getByTestId('post_options.delete_post.option'); + fireEvent.press(deleteOption); + + // Get the onPress function from the Delete button and call it + const alertCalls = mockAlert.mock.calls; + const deleteButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Delete'); + await deleteButtonConfig?.onPress!(); + + expect(mockDismissBottomSheet).toHaveBeenCalledWith('PostOptions'); + expect(mockDeletePost).toHaveBeenCalledWith(expect.any(String), mockPost); + }); + + it('should use combinedPost when provided for regular post', async () => { + const combinedPost = {id: 'combined-post-id'} as Post; + + renderWithEverything( + , + {database}, + ); + + const deleteOption = screen.getByTestId('post_options.delete_post.option'); + fireEvent.press(deleteOption); + + const alertCalls = mockAlert.mock.calls; + const deleteButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Delete'); + await deleteButtonConfig?.onPress!(); + + expect(mockDeletePost).toHaveBeenCalledWith(expect.any(String), combinedPost); + }); + }); + + describe('BoR Post Deletion', () => { + beforeEach(() => { + mockIsBoRPost.mockReturnValue(true); + }); + + it('should show BoR confirmation alert for sender', () => { + mockIsOwnBoRPost.mockReturnValue(true); + + renderWithEverything( + , + {database}, + ); + + const deleteOption = screen.getByTestId('post_options.delete_post.option'); + fireEvent.press(deleteOption); + + expect(mockAlert).toHaveBeenCalledWith( + 'Delete Message Now?', + 'This message will be permanently deleted for all recipients right away. This action can\'t be undone. Are you sure you want to delete this message?', + expect.arrayContaining([ + expect.objectContaining({ + text: 'Cancel', + style: 'cancel', + }), + expect.objectContaining({ + text: 'Delete', + style: 'destructive', + onPress: expect.any(Function), + }), + ]), + ); + }); + + it('should show BoR confirmation alert for receiver', () => { + mockIsOwnBoRPost.mockReturnValue(false); + + renderWithEverything( + , + {database}, + ); + + const deleteOption = screen.getByTestId('post_options.delete_post.option'); + fireEvent.press(deleteOption); + + expect(mockAlert).toHaveBeenCalledWith( + 'Delete Message Now?', + 'This message will be permanently deleted for you right away and can\'t be undone.', + expect.arrayContaining([ + expect.objectContaining({ + text: 'Cancel', + style: 'cancel', + }), + expect.objectContaining({ + text: 'Delete', + style: 'destructive', + onPress: expect.any(Function), + }), + ]), + ); + }); + + it('should call burnPostNow when confirmed for BoR post', async () => { + mockIsOwnBoRPost.mockReturnValue(true); + + renderWithEverything( + , + {database}, + ); + + const deleteOption = screen.getByTestId('post_options.delete_post.option'); + fireEvent.press(deleteOption); + + const alertCalls = mockAlert.mock.calls; + const deleteButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Delete'); + await deleteButtonConfig?.onPress!(); + + expect(mockDismissBottomSheet).toHaveBeenCalledWith('PostOptions'); + expect(mockBurnPostNow).toHaveBeenCalledWith(expect.any(String), mockPost); + }); + + it('should use combinedPost when provided for BoR post', async () => { + const combinedPost = {id: 'combined-post-id'} as Post; + mockIsOwnBoRPost.mockReturnValue(true); + + renderWithEverything( + , + {database}, + ); + + const deleteOption = screen.getByTestId('post_options.delete_post.option'); + fireEvent.press(deleteOption); + + const alertCalls = mockAlert.mock.calls; + const deleteButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Delete'); + await deleteButtonConfig?.onPress!(); + + expect(mockBurnPostNow).toHaveBeenCalledWith(expect.any(String), combinedPost); + }); + }); + + it('should not call any delete functions when cancel is pressed', () => { + mockIsBoRPost.mockReturnValue(false); + + renderWithEverything( + , + {database}, + ); + + const deleteOption = screen.getByTestId('post_options.delete_post.option'); + fireEvent.press(deleteOption); + + // Simulate pressing Cancel + const alertCalls = mockAlert.mock.calls; + const cancelButtonConfig = alertCalls[0][2]?.find((button: any) => button.text === 'Cancel'); + cancelButtonConfig?.onPress?.(); + + expect(mockDeletePost).not.toHaveBeenCalled(); + expect(mockBurnPostNow).not.toHaveBeenCalled(); + expect(mockDismissBottomSheet).not.toHaveBeenCalled(); + }); +}); diff --git a/app/screens/post_options/options/delete_post_option.tsx b/app/screens/post_options/options/delete_post_option.tsx index ff4e5bc29..28b2949c8 100644 --- a/app/screens/post_options/options/delete_post_option.tsx +++ b/app/screens/post_options/options/delete_post_option.tsx @@ -5,18 +5,21 @@ import React, {useCallback} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import {Alert} from 'react-native'; -import {deletePost} from '@actions/remote/post'; +import {burnPostNow, deletePost} from '@actions/remote/post'; import {BaseOption} from '@components/common_post_options'; import {useServerUrl} from '@context/server'; import {dismissBottomSheet} from '@screens/navigation'; +import {isBoRPost, isOwnBoRPost} from '@utils/bor'; import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; import type {AvailableScreens} from '@typings/screens/navigation'; type Props = { bottomSheetId: AvailableScreens; combinedPost?: Post | PostModel; post: PostModel; + currentUser?: UserModel; } const messages = defineMessages({ @@ -26,17 +29,41 @@ const messages = defineMessages({ }, }); -const DeletePostOption = ({bottomSheetId, combinedPost, post}: Props) => { +const DeletePostOption = ({bottomSheetId, combinedPost, post, currentUser}: Props) => { const serverUrl = useServerUrl(); const {formatMessage} = useIntl(); const onPress = useCallback(() => { - Alert.alert( - formatMessage({id: 'mobile.post.delete_title', defaultMessage: 'Delete Post'}), - formatMessage({ + let title: string; + let body: string; + let deleteAction: (serverUrl: string, postToDelete: PostModel | Post) => Promise; + + if (isBoRPost(post)) { + title = formatMessage({id: 'mobile.burn_on_read.delete_now.title', defaultMessage: 'Delete Message Now?'}); + body = formatMessage({ + id: 'mobile.burn_on_read.delete_now.receiver.body', + defaultMessage: 'This message will be permanently deleted for you right away and can\'t be undone.', + }); + deleteAction = burnPostNow; + + if (isOwnBoRPost(post, currentUser?.id)) { + body = formatMessage({ + id: 'mobile.burn_on_read.delete_now.sender.body', + defaultMessage: 'This message will be permanently deleted for all recipients right away. This action can\'t be undone. Are you sure you want to delete this message?', + }); + } + } else { + title = formatMessage({id: 'mobile.post.delete_title', defaultMessage: 'Delete Post'}); + body = formatMessage({ id: 'mobile.post.delete_question', defaultMessage: 'Are you sure you want to delete this post?', - }), + }); + deleteAction = deletePost; + } + + Alert.alert( + title, + body, [{ text: formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}), style: 'cancel', @@ -45,11 +72,12 @@ const DeletePostOption = ({bottomSheetId, combinedPost, post}: Props) => { style: 'destructive', onPress: async () => { await dismissBottomSheet(bottomSheetId); - deletePost(serverUrl, combinedPost || post); + deleteAction(serverUrl, combinedPost || post); }, }], ); - }, [formatMessage, bottomSheetId, serverUrl, combinedPost, post]); + + }, [bottomSheetId, combinedPost, currentUser, formatMessage, post, serverUrl]); return ( { const managedConfig = useManagedConfig(); const isTablet = useIsTablet(); @@ -71,15 +76,17 @@ const PostOptions = ({ const isSystemPost = isSystemMessage(post); - const canCopyBoRPostPermalink = isBoRPost ? post.userId === currentUserId : true; + const canCopyBoRPostPermalink = isBoRPost ? post.userId === currentUser?.id : true; const canCopyPermalink = !isSystemPost && managedConfig?.copyAndPasteProtection !== 'true' && canCopyBoRPostPermalink; const canCopyText = canCopyPermalink && post.message && !isBoRPost; - const canSavePost = !isSystemPost && (!isUnrevealedBoRPost(post) || isOwnBoRPost(post, currentUserId)); + const canSavePost = !isSystemPost && (!isUnrevealedBoRPost(post) || isOwnBoRPost(post, currentUser?.id)); const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread); const shouldShowBindings = bindings.length > 0 && !isSystemPost; + const shouldShowBORReadReceipts = showBoRReadReceipts && borReceiptData; + const snapPoints = useMemo(() => { const items: Array = [1]; const optionsCount = [ @@ -89,7 +96,11 @@ const PostOptions = ({ return v ? acc + 1 : acc; }, 0) + (shouldShowBindings ? 0.5 : 0); - items.push(bottomSheetSnapPoint(optionsCount, ITEM_HEIGHT) + (canAddReaction ? REACTION_PICKER_HEIGHT + REACTION_PICKER_MARGIN : 0)); + items.push( + bottomSheetSnapPoint(optionsCount, ITEM_HEIGHT) + + (canAddReaction ? REACTION_PICKER_HEIGHT + REACTION_PICKER_MARGIN : 0) + + (shouldShowBORReadReceipts ? BOR_READ_RECEIPTS_HEIGHT : 0), + ); if (shouldShowBindings) { items.push('80%'); @@ -109,6 +120,12 @@ const PostOptions = ({ scrollEnabled={enabled} {...panResponder.panHandlers} > + {shouldShowBORReadReceipts && + + } {canAddReaction && } {shouldShowBindings && { @@ -126,46 +128,48 @@ describe('formatTime', () => { }); describe('text time format', () => { + const intl = getIntlShape(); + it('should format seconds only in text time format', () => { - expect(formatTime(30, true)).toBe('30s'); - expect(formatTime(5, true)).toBe('5s'); - expect(formatTime(59, true)).toBe('59s'); + expect(formatTime(30, true, intl)).toBe('30s'); + expect(formatTime(5, true, intl)).toBe('5s'); + expect(formatTime(59, true, intl)).toBe('59s'); }); it('should format minutes and seconds in text time format', () => { - expect(formatTime(90, true)).toBe('1m 30s'); - expect(formatTime(125, true)).toBe('2m 5s'); - expect(formatTime(3599, true)).toBe('59m 59s'); + expect(formatTime(90, true, intl)).toBe('1m 30s'); + expect(formatTime(125, true, intl)).toBe('2m 5s'); + expect(formatTime(3599, true, intl)).toBe('59m 59s'); }); it('should format hours, minutes, and seconds in text time format', () => { - expect(formatTime(3600, true)).toBe('1h'); - expect(formatTime(3661, true)).toBe('1h 1m 1s'); - expect(formatTime(7325, true)).toBe('2h 2m 5s'); - expect(formatTime(36000, true)).toBe('10h'); + expect(formatTime(3600, true, intl)).toBe('1h'); + expect(formatTime(3661, true, intl)).toBe('1h 1m 1s'); + expect(formatTime(7325, true, intl)).toBe('2h 2m 5s'); + expect(formatTime(36000, true, intl)).toBe('10h'); }); it('should format hours and minutes only in text time format', () => { - expect(formatTime(3660, true)).toBe('1h 1m'); - expect(formatTime(7200, true)).toBe('2h'); + expect(formatTime(3660, true, intl)).toBe('1h 1m'); + expect(formatTime(7200, true, intl)).toBe('2h'); }); it('should format hours and seconds only in text time format', () => { - expect(formatTime(3605, true)).toBe('1h 5s'); + expect(formatTime(3605, true, intl)).toBe('1h 5s'); }); it('should format minutes only in text time format', () => { - expect(formatTime(60, true)).toBe('1m'); - expect(formatTime(120, true)).toBe('2m'); + expect(formatTime(60, true, intl)).toBe('1m'); + expect(formatTime(120, true, intl)).toBe('2m'); }); it('should handle zero seconds in text time format', () => { - expect(formatTime(0, true)).toBe('0s'); + expect(formatTime(0, true, intl)).toBe('0s'); }); it('should handle negative values in text time format', () => { - expect(formatTime(-30, true)).toBe('0s'); - expect(formatTime(-3600, true)).toBe('0s'); + expect(formatTime(-30, true, intl)).toBe('0s'); + expect(formatTime(-3600, true, intl)).toBe('0s'); }); }); }); diff --git a/app/utils/datetime.ts b/app/utils/datetime.ts index f1af27214..447fdfcc5 100644 --- a/app/utils/datetime.ts +++ b/app/utils/datetime.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {IntlShape} from 'react-intl'; + export function isSameDate(a: Date, b: Date = new Date()): boolean { return a.getDate() === b.getDate() && isSameMonth(a, b) && isSameYear(a, b); } @@ -56,23 +58,23 @@ export function getReadableTimestamp(timestamp: number, timeZone: string, isMili return date.toLocaleString(currentUserLocale, options); } -export function formatTime(seconds: number, textTime: boolean = false) { +export function formatTime(seconds: number, textTime: boolean = false, intl?: IntlShape) { const h = Math.max(Math.floor(seconds / 3600), 0); const m = Math.max(Math.floor((seconds % 3600) / 60), 0); const s = Math.max(Math.floor(seconds % 60), 0); - if (textTime) { + if (textTime && intl) { const parts: string[] = []; if (h > 0) { - parts.push(`${h}h`); + parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.hours_component', defaultMessage: '{hours}h'}, {hours: h})); } if (m > 0) { - parts.push(`${m}m`); + parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.minutes_component', defaultMessage: '{minutes}m'}, {minutes: m})); } if (s > 0) { - parts.push(`${s}s`); + parts.push(intl.formatMessage({id: 'mobile.format_time.text_time.seconds_component', defaultMessage: '{seconds}s'}, {seconds: s})); } - return parts.length > 0 ? parts.join(' ') : '0s'; + return parts.length > 0 ? parts.join(' ') : intl.formatMessage({id: 'mobile.format_time.text_time.seconds_component', defaultMessage: '{seconds}s'}, {seconds: 0}); } const hh = h > 0 ? `${h}:` : ''; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 82494e2a2..acf52bd43 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -540,7 +540,12 @@ "mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.", "mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos", "mobile.announcement_banner.title": "Announcement", + "mobile.burn_on_read.delete_now.receiver.body": "This message will be permanently deleted for you right away and can't be undone.", + "mobile.burn_on_read.delete_now.sender.body": "This message will be permanently deleted for all recipients right away. This action can't be undone. Are you sure you want to delete this message?", + "mobile.burn_on_read.delete_now.title": "Delete Message Now?", "mobile.burn_on_read.placeholder": "View message", + "mobile.burn_on_read.read_receipt.text": "Read by {readReceipts} of {totalReceipts} recipients", + "mobile.burn_on_read.read_receipt.title": "Burn-on-read message", "mobile.calls_audio_device": "Select audio device", "mobile.calls_bluetooth": "Bluetooth", "mobile.calls_call_ended": "Call ended", @@ -717,6 +722,9 @@ "mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.", "mobile.files_paste.error_dismiss": "Dismiss", "mobile.files_paste.error_title": "Paste failed", + "mobile.format_time.text_time.hours_component": "{hours}h", + "mobile.format_time.text_time.minutes_component": "{minutes}m", + "mobile.format_time.text_time.seconds_component": "{seconds}s", "mobile.gallery.title": "{index} of {total}", "mobile.integration_selector.loading_channels": "Loading channels...", "mobile.integration_selector.loading_options": "Loading options...", diff --git a/test/test_helper.ts b/test/test_helper.ts index a7b5d55db..d0fe838af 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -60,6 +60,7 @@ class TestHelperSingleton { basicCategory: Category | null; basicCategoryChannel: CategoryChannel | null; basicChannel: Channel | null; + basicChannelInfo: ChannelInfo | null; basicChannelMember: ChannelMembership | null; basicMyChannel: ChannelMembership | null; basicMyChannelSettings: ChannelMembership | null; @@ -75,6 +76,7 @@ class TestHelperSingleton { this.basicCategory = null; this.basicCategoryChannel = null; this.basicChannel = null; + this.basicChannelInfo = null; this.basicChannelMember = null; this.basicMyChannel = null; this.basicMyChannelSettings = null; @@ -118,6 +120,10 @@ class TestHelperSingleton { channels: [this.basicChannel!], prepareRecordsOnly: false, }); + await operator.handleChannelInfo({ + channelInfos: [this.basicChannelInfo!], + prepareRecordsOnly: false, + }); await operator.handleMyChannel({ prepareRecordsOnly: false, channels: [this.basicChannel!], @@ -1461,6 +1467,7 @@ class TestHelperSingleton { this.basicTeamMember = this.fakeTeamMember(this.basicUser.id, this.basicTeam.id); this.basicCategory = this.fakeCategoryWithId(this.basicTeam.id); this.basicChannel = this.fakeChannelWithId(this.basicTeam.id); + this.basicChannelInfo = this.fakeChannelInfo({id: this.basicChannel.id, member_count: 100}); this.basicCategoryChannel = this.fakeCategoryChannelWithId(this.basicTeam.id, this.basicCategory.id, this.basicChannel.id); this.basicChannelMember = this.fakeChannelMember({user_id: this.basicUser.id, channel_id: this.basicChannel.id}); this.basicMyChannel = this.fakeMyChannel({user_id: this.basicUser.id, channel_id: this.basicChannel.id}); diff --git a/types/api/config.d.ts b/types/api/config.d.ts index ea4c4787b..ea7a7e1bc 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -22,6 +22,8 @@ interface ClientConfig { BuildHash: string; BuildHashEnterprise: string; BuildNumber: string; + BurnOnReadMaximumTimeToLiveSeconds: string; + BurnOnReadDurationSeconds: string; CloseUnusedDirectMessages: string; CollapsedThreads: string; CustomBrandText: string; @@ -46,6 +48,7 @@ interface ClientConfig { EmailNotificationContentsType: string; EnableBanner: string; EnableBotAccountCreation: string; + EnableBurnOnRead: string; EnableChannelViewedMessages: string; EnableClientMetrics?: string; EnableCluster: string; diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index 53474f35a..b949c2841 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -80,6 +80,8 @@ type PostMetadata = { reactions?: Reaction[]; priority?: PostPriority; expire_at?: number; + borConfig?: PostBoRConfig; + recipients?: string[]; }; type Post = { @@ -178,3 +180,9 @@ type FetchPaginatedThreadOptions = { fromCreateAt?: number; fromPost?: string; } + +type PostBoRConfig = { + enabled: boolean; + borDurationSeconds: number; + borMaximumTimeToLiveSeconds: number; +} diff --git a/types/components/post_options.ts b/types/components/post_options.ts new file mode 100644 index 000000000..86508ea9a --- /dev/null +++ b/types/components/post_options.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type BurnOnReadRecipientData = { + revealedCount: number; + totalRecipients: number; +} diff --git a/types/database/raw_values.d.ts b/types/database/raw_values.d.ts index 1fa71e98f..6125f0911 100644 --- a/types/database/raw_values.d.ts +++ b/types/database/raw_values.d.ts @@ -24,7 +24,7 @@ type Draft = { root_id: string; metadata?: PostMetadata; update_at: number; - type?: string; + type?: PostType; }; type MyTeam = {