From b475f2bedbd4c93faaa46aa11880fe41d6ad5ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Tue, 18 Mar 2025 13:33:15 +0100 Subject: [PATCH] Fix team sidebar not showing thread unreads (#8624) * Fix team sidebar not showing thread unreads * Fix tests --------- Co-authored-by: Mattermost Build --- app/actions/local/post.test.ts | 19 +- app/actions/local/systems.test.ts | 2 +- app/actions/local/thread.test.ts | 14 +- app/actions/remote/entry/notification.test.ts | 11 +- app/actions/remote/notifications.test.ts | 2 +- app/actions/remote/post.test.ts | 6 +- app/actions/remote/preference.test.ts | 2 +- app/actions/remote/reactions.test.ts | 4 +- app/actions/remote/search.test.ts | 2 +- app/actions/remote/thread.test.ts | 2 +- app/components/files/files.test.tsx | 90 +++---- .../team_list/team_item/index.test.tsx | 189 ++++++++++++++ .../team_sidebar/team_list/team_item/index.ts | 18 +- app/hooks/files.test.ts | 36 +-- app/queries/servers/channel.test.ts | 239 +++++++++++++++++- app/queries/servers/channel.ts | 15 +- app/queries/servers/team.test.ts | 189 +++++++++++++- app/queries/servers/team.ts | 17 +- test/api_mocks/file.ts | 19 -- test/test_helper.ts | 86 +++++-- 20 files changed, 793 insertions(+), 169 deletions(-) create mode 100644 app/components/team_sidebar/team_list/team_item/index.test.tsx delete mode 100644 test/api_mocks/file.ts diff --git a/app/actions/local/post.test.ts b/app/actions/local/post.test.ts index f7a920fdd..5dde871d9 100644 --- a/app/actions/local/post.test.ts +++ b/app/actions/local/post.test.ts @@ -111,7 +111,7 @@ describe('sendEphemeralPost', () => { }); describe('removePost', () => { - const post = {...TestHelper.fakePost(channelId), id: 'postid'}; + const post = TestHelper.fakePost({id: 'postid', channel_id: channelId}); it('handle not found database', async () => { const {post: rPost, error} = await removePost('foo', post); @@ -139,12 +139,12 @@ describe('removePost', () => { }); it('base case - system message', async () => { - const systemPost = {...TestHelper.fakePost(channelId), id: `${COMBINED_USER_ACTIVITY}id1_id2`, type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType, props: {system_post_ids: ['id1']}}; + const systemPost = TestHelper.fakePost({channel_id: channelId, id: `${COMBINED_USER_ACTIVITY}id1_id2`, type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType, props: {system_post_ids: ['id1']}}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id, 'id1'], - posts: [systemPost, {...TestHelper.fakePost(channelId), id: 'id1'}], + posts: [systemPost, TestHelper.fakePost({id: 'id1', channel_id: channelId})], prepareRecordsOnly: false, }); @@ -155,7 +155,7 @@ describe('removePost', () => { }); describe('markPostAsDeleted', () => { - const post = TestHelper.fakePost(channelId); + const post = TestHelper.fakePost({channel_id: channelId}); it('handle not found database', async () => { const {model, error} = await markPostAsDeleted('foo', post); @@ -185,8 +185,7 @@ describe('markPostAsDeleted', () => { }); describe('storePostsForChannel', () => { - const post = TestHelper.fakePost(channelId); - post.user_id = user.id; + const post = TestHelper.fakePost({channel_id: channelId, user_id: user.id}); const teamId = 'tId1'; const channel: Channel = { id: channelId, @@ -226,7 +225,7 @@ describe('storePostsForChannel', () => { }); describe('getPosts', () => { - const post = TestHelper.fakePost(channelId); + const post = TestHelper.fakePost({channel_id: channelId}); it('handle not found database', async () => { const posts = await getPosts('foo', [post.id]); @@ -249,7 +248,7 @@ describe('getPosts', () => { }); describe('addPostAcknowledgement', () => { - const post = TestHelper.fakePost(channelId); + const post = TestHelper.fakePost({channel_id: channelId}); it('handle not found database', async () => { const {model, error} = await addPostAcknowledgement('foo', post.id, user.id, 123, false); @@ -292,7 +291,7 @@ describe('addPostAcknowledgement', () => { }); describe('removePostAcknowledgement', () => { - const post = TestHelper.fakePost(channelId); + const post = TestHelper.fakePost({channel_id: channelId}); it('handle not found database', async () => { const {model, error} = await removePostAcknowledgement('foo', post.id, user.id, false); @@ -321,7 +320,7 @@ describe('removePostAcknowledgement', () => { }); describe('deletePosts', () => { - const post = TestHelper.fakePost(channelId); + const post = TestHelper.fakePost({channel_id: channelId}); it('handle not found database', async () => { const {error} = await deletePosts('foo', [post.id]); diff --git a/app/actions/local/systems.test.ts b/app/actions/local/systems.test.ts index b4cda2eb8..a1a8a9b67 100644 --- a/app/actions/local/systems.test.ts +++ b/app/actions/local/systems.test.ts @@ -140,7 +140,7 @@ describe('dataRetention', () => { }); it('rentention off - dataRetentionCleanup', async () => { - const post = {...TestHelper.fakePost('channelid1'), id: 'postid', create_at: 1}; + const post = TestHelper.fakePost({channel_id: 'channelid1', id: 'postid', create_at: 1}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], diff --git a/app/actions/local/thread.test.ts b/app/actions/local/thread.test.ts index b12879cc0..dc6ef2173 100644 --- a/app/actions/local/thread.test.ts +++ b/app/actions/local/thread.test.ts @@ -65,7 +65,7 @@ const user2: UserProfile = { roles: '', } as UserProfile; -const rootPost = {...TestHelper.fakePost(channelId, user.id), id: 'rootpostid', create_at: 1}; +const rootPost = TestHelper.fakePost({channel_id: channelId, user_id: user.id, id: 'rootpostid', create_at: 1}); const threads = [ { id: rootPost.id, @@ -147,7 +147,7 @@ describe('switchToThread', () => { it('handle no channel', async () => { await operator.handleUsers({users: [user], prepareRecordsOnly: false}); await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); - const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1}; + const post = TestHelper.fakePost({channel_id: channelId, user_id: user2.id, id: 'postid', create_at: 1}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], @@ -165,7 +165,7 @@ describe('switchToThread', () => { await operator.handleTeam({teams: [team], prepareRecordsOnly: false}); await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'teamid2'}, {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); - const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1}; + const post = TestHelper.fakePost({channel_id: channelId, user_id: user2.id, id: 'postid', create_at: 1}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], @@ -183,7 +183,7 @@ describe('switchToThread', () => { await operator.handleTeam({teams: [team], prepareRecordsOnly: false}); await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'teamid2'}, {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); await operator.handleChannel({channels: [channel], prepareRecordsOnly: false}); - const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1}; + const post = TestHelper.fakePost({channel_id: channelId, user_id: user2.id, id: 'postid', create_at: 1}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], @@ -201,7 +201,7 @@ describe('switchToThread', () => { await operator.handleTeam({teams: [team], prepareRecordsOnly: false}); await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'teamid2'}, {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); await operator.handleChannel({channels: [{...channel, team_id: '', type: 'D', display_name: 'user1-user2'}], prepareRecordsOnly: false}); - const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1}; + const post = TestHelper.fakePost({channel_id: channelId, user_id: user2.id, id: 'postid', create_at: 1}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], @@ -224,7 +224,7 @@ describe('createThreadFromNewPost', () => { it('base case', async () => { await operator.handleUsers({users: [user, user2], prepareRecordsOnly: false}); await operator.handleThreads({threads, prepareRecordsOnly: false, teamId: team.id}); - const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1, root_id: rootPost.id}; + const post = TestHelper.fakePost({channel_id: channelId, user_id: user2.id, id: 'postid', create_at: 1, root_id: rootPost.id}); const {models, error} = await createThreadFromNewPost(serverUrl, post, false); expect(error).toBeUndefined(); @@ -234,7 +234,7 @@ describe('createThreadFromNewPost', () => { it('base case - no root post', async () => { await operator.handleUsers({users: [user2], prepareRecordsOnly: false}); - const post = {...TestHelper.fakePost(channelId, user2.id), id: 'postid', create_at: 1}; + const post = TestHelper.fakePost({channel_id: channelId, user_id: user2.id, id: 'postid', create_at: 1}); const {models, error} = await createThreadFromNewPost(serverUrl, post); expect(error).toBeUndefined(); diff --git a/app/actions/remote/entry/notification.test.ts b/app/actions/remote/entry/notification.test.ts index 7b25eb41e..086f1ec00 100644 --- a/app/actions/remote/entry/notification.test.ts +++ b/app/actions/remote/entry/notification.test.ts @@ -62,7 +62,7 @@ describe('Performance metrics are set correctly', () => { expect(client).toBeTruthy(); operator = (await TestHelper.setupServerDatabase(serverUrl)).operator; await DatabaseManager.setActiveServerDatabase(serverUrl); - post = TestHelper.fakePost(TestHelper.basicChannel!.id); + post = TestHelper.fakePost({channel_id: TestHelper.basicChannel!.id}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [post.id], @@ -101,8 +101,7 @@ describe('Performance metrics are set correctly', () => { }); it('thread notification', async () => { - const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id); - commentPost.root_id = post.id; + const commentPost = TestHelper.fakePost({channel_id: TestHelper.basicChannel!.id, root_id: post.id}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [commentPost.id], @@ -134,8 +133,7 @@ describe('Performance metrics are set correctly', () => { }); it('thread notification with wrong root id', async () => { - const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id); - commentPost.root_id = post.id; + const commentPost = TestHelper.fakePost({channel_id: TestHelper.basicChannel!.id, root_id: post.id}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [commentPost.id], @@ -167,8 +165,7 @@ describe('Performance metrics are set correctly', () => { }); it('thread notification with non crt', async () => { - const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id); - commentPost.root_id = post.id; + const commentPost = TestHelper.fakePost({channel_id: TestHelper.basicChannel!.id, root_id: post.id}); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [commentPost.id], diff --git a/app/actions/remote/notifications.test.ts b/app/actions/remote/notifications.test.ts index ad3d3467d..a8d53ce51 100644 --- a/app/actions/remote/notifications.test.ts +++ b/app/actions/remote/notifications.test.ts @@ -39,7 +39,7 @@ const channel: Channel = { const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile; -const post1 = {...TestHelper.fakePost(channelId), id: 'postid1'}; +const post1 = TestHelper.fakePost({channel_id: channelId, id: 'postid1'}); const notificationExtraData = { channel, diff --git a/app/actions/remote/post.test.ts b/app/actions/remote/post.test.ts index 9c67ee5a3..a4ee437d4 100644 --- a/app/actions/remote/post.test.ts +++ b/app/actions/remote/post.test.ts @@ -44,9 +44,9 @@ const teamId = 'teamid1'; const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile; const user2 = {id: 'userid2', username: 'user2', email: 'user2@mattermost.com', roles: ''} as UserProfile; -const post1 = {...TestHelper.fakePost(channelId), id: 'postid1', user_id: user1.id}; -const post2 = {...TestHelper.fakePost(channelId), id: 'postid2', user_id: user2.id}; -const reply1 = {...TestHelper.fakePost(channelId), id: 'replyid1', root_id: post1.id, user_id: user2.id}; +const post1 = TestHelper.fakePost({channel_id: channelId, id: 'postid1', user_id: user1.id}); +const post2 = TestHelper.fakePost({channel_id: channelId, id: 'postid2', user_id: user2.id}); +const reply1 = TestHelper.fakePost({channel_id: channelId, id: 'replyid1', root_id: post1.id, user_id: user2.id}); const channel1 = { id: channelId, diff --git a/app/actions/remote/preference.test.ts b/app/actions/remote/preference.test.ts index 320494026..70b916272 100644 --- a/app/actions/remote/preference.test.ts +++ b/app/actions/remote/preference.test.ts @@ -40,7 +40,7 @@ const channel: Channel = { const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile; const preference1 = {category: 'category1', name: 'name1', user_id: user1.id, value: 'value1'} as PreferenceType; -const post1 = {...TestHelper.fakePost(channelId), id: 'postid1'}; +const post1 = TestHelper.fakePost({channel_id: channelId, id: 'postid1'}); const throwFunc = () => { throw Error('error'); diff --git a/app/actions/remote/reactions.test.ts b/app/actions/remote/reactions.test.ts index 9ccc7c4f0..09c7c3974 100644 --- a/app/actions/remote/reactions.test.ts +++ b/app/actions/remote/reactions.test.ts @@ -33,8 +33,8 @@ const team: Team = { const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile; -const post1 = {...TestHelper.fakePost(channelId), id: 'postid1'}; -const post2 = {...TestHelper.fakePost(channelId), id: 'postid2', root_id: post1.id}; +const post1 = TestHelper.fakePost({channel_id: channelId, id: 'postid1'}); +const post2 = TestHelper.fakePost({channel_id: channelId, id: 'postid2', root_id: post1.id}); const thread1: Thread = {id: 'postid1', reply_count: 1, diff --git a/app/actions/remote/search.test.ts b/app/actions/remote/search.test.ts index 0c40643fb..1d4e8b0af 100644 --- a/app/actions/remote/search.test.ts +++ b/app/actions/remote/search.test.ts @@ -30,7 +30,7 @@ const channel: Channel = { } as Channel; const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile; -const post1 = {...TestHelper.fakePost(channelId), id: 'postid1'}; +const post1 = TestHelper.fakePost({channel_id: channelId, id: 'postid1'}); const fileInfo1: FileInfo = { id: 'fileid1', diff --git a/app/actions/remote/thread.test.ts b/app/actions/remote/thread.test.ts index 293da7cc7..54c943dd5 100644 --- a/app/actions/remote/thread.test.ts +++ b/app/actions/remote/thread.test.ts @@ -37,7 +37,7 @@ const team: Team = { const user1 = {id: 'userid1', username: 'user1', email: 'user1@mattermost.com', roles: ''} as UserProfile; -const post1 = {...TestHelper.fakePost(channelId), id: 'postid1'}; +const post1 = TestHelper.fakePost({channel_id: channelId, id: 'postid1'}); const thread1: Thread = {id: 'postid1', reply_count: 1, diff --git a/app/components/files/files.test.tsx b/app/components/files/files.test.tsx index 3825057a5..63de3fb5b 100644 --- a/app/components/files/files.test.tsx +++ b/app/components/files/files.test.tsx @@ -7,7 +7,7 @@ import {DeviceEventEmitter, Text, TouchableOpacity, View} from 'react-native'; import {Events} from '@constants'; import {useIsTablet} from '@hooks/device'; import {useImageAttachments} from '@hooks/files'; -import {mockFileInfo} from '@test/api_mocks/file'; +import TestHelper from '@test/test_helper'; import {isImage, isVideo} from '@utils/file'; import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; import {getViewPortWidth} from '@utils/images'; @@ -89,10 +89,10 @@ function getBaseProps(): ComponentProps { describe('Files', () => { it('should render attachments, with images in the image row', () => { const filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '2'}), - mockFileInfo({id: '3'}), - mockFileInfo({id: '4'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '2'}), + TestHelper.fakeFileInfo({id: '3'}), + TestHelper.fakeFileInfo({id: '4'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { return useMemo(() => ({ @@ -119,8 +119,8 @@ describe('Files', () => { it('should not show the image row if no images', () => { const filesInfo = [ - mockFileInfo({id: '3'}), - mockFileInfo({id: '4'}), + TestHelper.fakeFileInfo({id: '3'}), + TestHelper.fakeFileInfo({id: '4'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { return useMemo(() => ({ @@ -158,8 +158,8 @@ describe('Files', () => { it('should drill all relevant props', () => { const filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '2'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '2'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { @@ -210,8 +210,8 @@ describe('Files', () => { it('should set layoutWidth if provided', () => { const filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '2'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '2'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { @@ -239,8 +239,8 @@ describe('Files', () => { it('should use ((getViewportWidth result) - 6) if layoutWidth is not provided', () => { const filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '2'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '2'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { @@ -276,8 +276,8 @@ describe('Files', () => { it('calling onPress on the child should open gallery', () => { const filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '2'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '2'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { @@ -322,8 +322,8 @@ describe('Files', () => { it('calling updateFileForGallery updates the file', () => { const filesInfo = [ - mockFileInfo({id: '1', uri: 'original'}), - mockFileInfo({id: '2', uri: 'original'}), + TestHelper.fakeFileInfo({id: '1', uri: 'original'}), + TestHelper.fakeFileInfo({id: '2', uri: 'original'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { @@ -375,7 +375,7 @@ describe('Files', () => { />, ); const newFilesInfo = [ - mockFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}), + TestHelper.fakeFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}), ]; rerender( { it('should set inViewPort to true on ITEM_IN_VIEWPORT event', () => { const filesInfo = [ - mockFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}), - mockFileInfo({id: '2', name: 'image2.png', user_id: 'user2'}), + TestHelper.fakeFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}), + TestHelper.fakeFileInfo({id: '2', name: 'image2.png', user_id: 'user2'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { @@ -418,8 +418,8 @@ describe('Files', () => { it('should ignore ITEM_IN_VIEWPORT event if not for the current post or location', () => { const filesInfo = [ - mockFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}), - mockFileInfo({id: '2', name: 'image2.png', user_id: 'user2'}), + TestHelper.fakeFileInfo({id: '1', name: 'image1.png', user_id: 'user1'}), + TestHelper.fakeFileInfo({id: '2', name: 'image2.png', user_id: 'user2'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { @@ -464,9 +464,9 @@ describe('Files', () => { it('should pass isSingleImage to the children', () => { let filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '3'}), - mockFileInfo({id: '4'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '3'}), + TestHelper.fakeFileInfo({id: '4'}), ]; const selectImages = (f: FileInfo | FileModel | undefined) => f?.id === '1' || f?.id === '2'; @@ -506,10 +506,10 @@ describe('Files', () => { expect(getByTestId('4-isSingleImage')).toHaveTextContent('true'); filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '2'}), - mockFileInfo({id: '3'}), - mockFileInfo({id: '4'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '2'}), + TestHelper.fakeFileInfo({id: '3'}), + TestHelper.fakeFileInfo({id: '4'}), ]; jest.mocked(isImage).mockImplementation((f) => selectImages(f)); @@ -544,16 +544,16 @@ describe('Files', () => { it('should trim more than 4 images and properly add the non visible images count to the last image', () => { const filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '2'}), - mockFileInfo({id: '3'}), - mockFileInfo({id: '4'}), - mockFileInfo({id: '5'}), - mockFileInfo({id: '6'}), - mockFileInfo({id: '7'}), - mockFileInfo({id: '8'}), - mockFileInfo({id: '9'}), - mockFileInfo({id: '10'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '2'}), + TestHelper.fakeFileInfo({id: '3'}), + TestHelper.fakeFileInfo({id: '4'}), + TestHelper.fakeFileInfo({id: '5'}), + TestHelper.fakeFileInfo({id: '6'}), + TestHelper.fakeFileInfo({id: '7'}), + TestHelper.fakeFileInfo({id: '8'}), + TestHelper.fakeFileInfo({id: '9'}), + TestHelper.fakeFileInfo({id: '10'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { @@ -584,12 +584,12 @@ describe('Files', () => { it('should add gutter to the container of to all elements but the first only on image row', () => { const filesInfo = [ - mockFileInfo({id: '1'}), - mockFileInfo({id: '2'}), - mockFileInfo({id: '3'}), - mockFileInfo({id: '4'}), - mockFileInfo({id: '5'}), - mockFileInfo({id: '6'}), + TestHelper.fakeFileInfo({id: '1'}), + TestHelper.fakeFileInfo({id: '2'}), + TestHelper.fakeFileInfo({id: '3'}), + TestHelper.fakeFileInfo({id: '4'}), + TestHelper.fakeFileInfo({id: '5'}), + TestHelper.fakeFileInfo({id: '6'}), ]; jest.mocked(useImageAttachments).mockImplementation((fi) => { diff --git a/app/components/team_sidebar/team_list/team_item/index.test.tsx b/app/components/team_sidebar/team_list/team_item/index.test.tsx new file mode 100644 index 000000000..edc35edde --- /dev/null +++ b/app/components/team_sidebar/team_list/team_item/index.test.tsx @@ -0,0 +1,189 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; +import React from 'react'; + +import {processReceivedThreads} from '@actions/local/thread'; +import {Config} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {prepareAllMyChannels} from '@queries/servers/channel'; +import {renderWithEverything, act} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import TeamItem from './team_item'; + +import EnhancedTeamItem from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type MyTeamModel from '@typings/database/models/servers/my_team'; + +jest.mock('./team_item', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(TeamItem).mockImplementation((props) => React.createElement('TeamItem', {...props, testID: 'team-item'})); + +describe('TeamItem enhanced component', () => { + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + const myTeam = {id: teamId} as MyTeamModel; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleTeam({teams: [TestHelper.fakeTeam({id: teamId})], prepareRecordsOnly: false}); + await operator.handleConfigs({ + configs: [ + {id: 'CollapsedThreads', value: Config.ALWAYS_ON}, + {id: 'Version', value: '7.6.0'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should correctly show hasUnreads if we have unread channels', async () => { + let models = await Promise.all(await prepareAllMyChannels(operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 10})], + [TestHelper.fakeMyChannel({id: 'my_channel1', channel_id: 'channel1', msg_count: 10})], + false, + )); + await operator.batchRecords(models.flat(), 'test'); + + const {getByTestId} = renderWithEverything(, {database}); + const teamItem = getByTestId('team-item'); + expect(teamItem.props.hasUnreads).toBe(false); + + await act(async () => { + models = await Promise.all(await prepareAllMyChannels(operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({id: 'my_channel1', channel_id: 'channel1', msg_count: 10})], + false, + )); + await operator.batchRecords(models.flat(), 'test'); + }); + + expect(teamItem.props.hasUnreads).toBe(true); + + await act(async () => { + models = await Promise.all(await prepareAllMyChannels(operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({id: 'my_channel1', channel_id: 'channel1', msg_count: 20})], + false, + )); + await operator.batchRecords(models.flat(), 'test'); + }); + + expect(teamItem.props.hasUnreads).toBe(false); + }); + + it('should correctly show hasUnreads if we have unread threads', async () => { + const channelId = 'channel1'; + const threadId = 'thread1'; + const channelModels = await Promise.all(await prepareAllMyChannels(operator, + [TestHelper.fakeChannel({id: channelId, team_id: teamId, total_msg_count: 10})], + [TestHelper.fakeMyChannel({id: 'my_channel1', channel_id: channelId, msg_count: 10})], + false, + )); + const threadModels = await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 0, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, true); + await operator.batchRecords([...channelModels.flat(), ...threadModels.models!], 'test'); + + const {getByTestId} = renderWithEverything(, {database}); + const teamItem = getByTestId('team-item'); + expect(teamItem.props.hasUnreads).toBe(false); + + await act(async () => { + await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 1, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, false); + }); + + expect(teamItem.props.hasUnreads).toBe(true); + + await act(async () => { + await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 0, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, false); + }); + + expect(teamItem.props.hasUnreads).toBe(false); + }); + + it('should not consider unread threads if collapsed threads is off', async () => { + const channelId = 'channel1'; + const threadId = 'thread1'; + + await operator.handleConfigs({ + configs: [{id: 'CollapsedThreads', value: Config.DISABLED}], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const channelModels = await Promise.all(await prepareAllMyChannels(operator, + [TestHelper.fakeChannel({id: channelId, team_id: teamId, total_msg_count: 10})], + [TestHelper.fakeMyChannel({id: 'my_channel1', channel_id: channelId, msg_count: 10})], + false, + )); + const threadModels = await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 0, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, true); + await operator.batchRecords([...channelModels.flat(), ...threadModels.models!], 'test'); + + const {getByTestId} = renderWithEverything(, {database}); + const teamItem = getByTestId('team-item'); + expect(teamItem.props.hasUnreads).toBe(false); + + await act(async () => { + await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 1, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, false); + }); + + expect(teamItem.props.hasUnreads).toBe(false); + + await act(async () => { + const models = await Promise.all(await prepareAllMyChannels(operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({id: 'my_channel1', channel_id: 'channel1', msg_count: 10})], + false, + )); + await operator.batchRecords(models.flat(), 'test'); + }); + + expect(teamItem.props.hasUnreads).toBe(true); + }); +}); diff --git a/app/components/team_sidebar/team_list/team_item/index.ts b/app/components/team_sidebar/team_list/team_item/index.ts index 26efcf01e..439420059 100644 --- a/app/components/team_sidebar/team_list/team_item/index.ts +++ b/app/components/team_sidebar/team_list/team_item/index.ts @@ -3,11 +3,10 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; -import {combineLatestWith, map, switchMap, distinctUntilChanged} from 'rxjs/operators'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; -import {observeAllMyChannelNotifyProps, queryMyChannelsByTeam} from '@queries/servers/channel'; import {observeCurrentTeamId} from '@queries/servers/system'; -import {observeMentionCount, observeTeam} from '@queries/servers/team'; +import {observeIsTeamUnread, observeMentionCount, observeTeam} from '@queries/servers/team'; import TeamItem from './team_item'; @@ -19,17 +18,6 @@ type WithTeamsArgs = WithDatabaseArgs & { } const enhance = withObservables(['myTeam'], ({myTeam, database}: WithTeamsArgs) => { - const myChannels = queryMyChannelsByTeam(database, myTeam.id).observeWithColumns(['mentions_count', 'is_unread']); - const notifyProps = observeAllMyChannelNotifyProps(database); - const hasUnreads = myChannels.pipe( - combineLatestWith(notifyProps), - // eslint-disable-next-line max-nested-callbacks - map(([mycs, notify]) => mycs.reduce((acc, v) => { - const isMuted = notify?.[v.id]?.mark_unread === 'mention'; - return acc || (v.isUnread && !isMuted); - }, false)), - ); - const selected = observeCurrentTeamId(database).pipe( switchMap((ctid) => of$(ctid === myTeam.id)), distinctUntilChanged(), @@ -39,7 +27,7 @@ const enhance = withObservables(['myTeam'], ({myTeam, database}: WithTeamsArgs) selected, team: observeTeam(database, myTeam.id), mentionCount: observeMentionCount(database, myTeam.id, false), - hasUnreads, + hasUnreads: observeIsTeamUnread(database, myTeam.id), }; }); diff --git a/app/hooks/files.test.ts b/app/hooks/files.test.ts index 5d293aaaf..498e56a4b 100644 --- a/app/hooks/files.test.ts +++ b/app/hooks/files.test.ts @@ -6,7 +6,7 @@ import {renderHook} from '@testing-library/react-hooks'; import {getLocalFileInfo} from '@actions/local/file'; import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file'; import {useServerUrl} from '@context/server'; -import {mockFileInfo} from '@test/api_mocks/file'; +import TestHelper from '@test/test_helper'; import {isGif, isImage, isVideo} from '@utils/file'; import {getImageSize} from '@utils/gallery'; @@ -42,9 +42,9 @@ describe('useImageAttachments', () => { it('should separate images and non-images correctly', () => { const filesInfo = [ - mockFileInfo({id: '1', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}), - mockFileInfo({id: '2', localPath: 'path/to/video1', uri: `${serverUrl}/files/video1`}), - mockFileInfo({id: '3', localPath: 'path/to/file1', uri: `${serverUrl}/files/file1`}), + TestHelper.fakeFileInfo({id: '1', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}), + TestHelper.fakeFileInfo({id: '2', localPath: 'path/to/video1', uri: `${serverUrl}/files/video1`}), + TestHelper.fakeFileInfo({id: '3', localPath: 'path/to/file1', uri: `${serverUrl}/files/file1`}), ]; jest.mocked(isImage).mockImplementation((file) => file?.id === '1'); @@ -56,18 +56,18 @@ describe('useImageAttachments', () => { const {result} = renderHook(() => useImageAttachments(filesInfo)); expect(result.current.images).toEqual([ - mockFileInfo({id: '1', localPath: 'path/to/image1', uri: 'path/to/image1'}), - mockFileInfo({id: '2', localPath: 'path/to/video1', uri: 'path/to/video1'}), + TestHelper.fakeFileInfo({id: '1', localPath: 'path/to/image1', uri: 'path/to/image1'}), + TestHelper.fakeFileInfo({id: '2', localPath: 'path/to/video1', uri: 'path/to/video1'}), ]); expect(result.current.nonImages).toEqual([ - mockFileInfo({id: '3', localPath: 'path/to/file1', uri: `${serverUrl}/files/file1`}), + TestHelper.fakeFileInfo({id: '3', localPath: 'path/to/file1', uri: `${serverUrl}/files/file1`}), ]); }); it('should use preview URL for images without localPath', () => { const filesInfo = [ - mockFileInfo({id: '1', localPath: '', uri: `${serverUrl}/files/image1`}), + TestHelper.fakeFileInfo({id: '1', localPath: '', uri: `${serverUrl}/files/image1`}), ]; jest.mocked(isImage).mockReturnValue(true); @@ -78,14 +78,14 @@ describe('useImageAttachments', () => { const {result} = renderHook(() => useImageAttachments(filesInfo)); expect(result.current.images).toEqual([ - mockFileInfo({id: '1', localPath: '', uri: 'https://example.com/preview/1'}), + TestHelper.fakeFileInfo({id: '1', localPath: '', uri: 'https://example.com/preview/1'}), ]); }); it('should use file URL for gifs and videos without local path', () => { const filesInfo = [ - mockFileInfo({id: '1', localPath: '', uri: `${serverUrl}/files/image1`}), - mockFileInfo({id: '2', localPath: '', uri: `${serverUrl}/files/video1`}), + TestHelper.fakeFileInfo({id: '1', localPath: '', uri: `${serverUrl}/files/image1`}), + TestHelper.fakeFileInfo({id: '2', localPath: '', uri: `${serverUrl}/files/video1`}), ]; jest.mocked(isImage).mockImplementation((file) => file?.id === '1' || file?.id === '3'); @@ -96,15 +96,15 @@ describe('useImageAttachments', () => { const {result} = renderHook(() => useImageAttachments(filesInfo)); expect(result.current.images).toEqual([ - mockFileInfo({id: '1', localPath: '', uri: 'https://example.com/file/1'}), - mockFileInfo({id: '2', localPath: '', uri: 'https://example.com/file/2'}), + TestHelper.fakeFileInfo({id: '1', localPath: '', uri: 'https://example.com/file/1'}), + TestHelper.fakeFileInfo({id: '2', localPath: '', uri: 'https://example.com/file/2'}), ]); }); it('should return the same values if the same arguments are passed', () => { const filesInfo = [ - mockFileInfo({id: '1', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}), - mockFileInfo({id: '2', localPath: 'path/to/video1', uri: `${serverUrl}/files/video1`}), + TestHelper.fakeFileInfo({id: '1', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}), + TestHelper.fakeFileInfo({id: '2', localPath: 'path/to/video1', uri: `${serverUrl}/files/video1`}), ]; jest.mocked(isImage).mockImplementation((file) => file?.id === '1'); @@ -135,8 +135,8 @@ describe('useImageAttachments', () => { it('should handle files with no id', () => { const filesInfo = [ - mockFileInfo({id: '', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}), - mockFileInfo({id: '', localPath: '', uri: `${serverUrl}/files/image2`}), + TestHelper.fakeFileInfo({id: '', localPath: 'path/to/image1', uri: `${serverUrl}/files/image1`}), + TestHelper.fakeFileInfo({id: '', localPath: '', uri: `${serverUrl}/files/image2`}), ]; jest.mocked(isImage).mockReturnValue(true); @@ -147,7 +147,7 @@ describe('useImageAttachments', () => { const {result} = renderHook(() => useImageAttachments(filesInfo)); expect(result.current.images).toEqual([ - mockFileInfo({id: '', localPath: 'path/to/image1', uri: 'path/to/image1'}), + TestHelper.fakeFileInfo({id: '', localPath: 'path/to/image1', uri: 'path/to/image1'}), ]); }); }); diff --git a/app/queries/servers/channel.test.ts b/app/queries/servers/channel.test.ts index 2d3d5c86c..2b98bd74e 100644 --- a/app/queries/servers/channel.test.ts +++ b/app/queries/servers/channel.test.ts @@ -8,7 +8,9 @@ import {of as of$} from 'rxjs'; import {General, Permissions} from '@constants'; import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; import ServerDataOperator from '@database/operator/server_data_operator'; +import TestHelper from '@test/test_helper'; import {hasPermission} from '@utils/role'; import {prepareChannels, @@ -62,6 +64,8 @@ import {prepareChannels, queryChannelMembers, queryChannelsForAutocomplete, observeChannelMembers, + observeMyChannelUnreads, + prepareAllMyChannels, } from './channel'; import {queryRoles} from './role'; import {getCurrentChannelId, observeCurrentChannelId, observeCurrentUserId} from './system'; @@ -788,15 +792,15 @@ describe('Channel Functions', () => { describe('getChannelById', () => { it('should return the channel if found', async () => { const channelId = 'channel_id'; - const mockChannel = {id: channelId} as ChannelModel; + const mockedChannel = {id: channelId} as ChannelModel; (database.get as jest.Mock).mockReturnValue({ - find: jest.fn().mockResolvedValue(mockChannel), + find: jest.fn().mockResolvedValue(mockedChannel), }); const result = await getChannelById(database, channelId); expect(database.get).toHaveBeenCalledWith(MM_TABLES.SERVER.CHANNEL); - expect(result).toEqual(mockChannel); + expect(result).toEqual(mockedChannel); }); it('should return undefined if the channel is not found', async () => { @@ -854,17 +858,17 @@ describe('Channel Functions', () => { it('should return the channel if found', async () => { const teamId = 'team_id'; const channelName = 'channel_name'; - const mockChannel = {id: 'channel_id', name: channelName} as ChannelModel; + const mockedChannel = {id: 'channel_id', name: channelName} as ChannelModel; (database.get as jest.Mock).mockReturnValue({ query: jest.fn().mockReturnValue({ - fetch: jest.fn().mockResolvedValue([mockChannel]), + fetch: jest.fn().mockResolvedValue([mockedChannel]), }), }); const result = await getChannelByName(database, teamId, channelName); expect(database.get).toHaveBeenCalledWith(MM_TABLES.SERVER.CHANNEL); - expect(result).toEqual(mockChannel); + expect(result).toEqual(mockedChannel); }); it('should return undefined if the channel is not found', async () => { @@ -1033,17 +1037,17 @@ describe('Channel Functions', () => { describe('getCurrentChannel', () => { it('should return the current channel if found', async () => { const currentChannelId = 'current_channel_id'; - const mockChannel = {id: currentChannelId} as ChannelModel; + const mockedChannel = {id: currentChannelId} as ChannelModel; (getCurrentChannelId as jest.Mock).mockResolvedValue(currentChannelId); (database.get as jest.Mock).mockReturnValue({ - find: jest.fn().mockResolvedValue(mockChannel), + find: jest.fn().mockResolvedValue(mockedChannel), }); const result = await getCurrentChannel(database); expect(getCurrentChannelId).toHaveBeenCalledWith(database); expect(database.get).toHaveBeenCalledWith(MM_TABLES.SERVER.CHANNEL); - expect(result).toEqual(mockChannel); + expect(result).toEqual(mockedChannel); }); it('should return undefined if no current channel is found', async () => { @@ -1085,9 +1089,9 @@ describe('Channel Functions', () => { describe('observeCurrentChannel', () => { it('should observe the current channel', () => { const currentChannelId = 'current_channel_id'; - const mockChannel = {id: currentChannelId, observe: jest.fn().mockReturnValue(of$({id: currentChannelId}))} as unknown as ChannelModel; + const mockedChannel = {id: currentChannelId, observe: jest.fn().mockReturnValue(of$({id: currentChannelId}))} as unknown as ChannelModel; const mockQuery = jest.fn().mockReturnValue({ - observe: jest.fn().mockReturnValue(of$([mockChannel])), + observe: jest.fn().mockReturnValue(of$([mockedChannel])), }); (database.get as jest.Mock).mockReturnValue({ query: mockQuery, @@ -1552,3 +1556,216 @@ describe('Channel Observations', () => { }); }); }); + +describe('observeMyChannelUnreads', () => { + const teamId = 'team_id'; + const userId = 'user_id'; + const serverUrl = 'baseHandler.test.com'; + const waitTime = 50; + let database: Database; + let operator: ServerDataOperator; + + jest.restoreAllMocks(); + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.deleteServerDatabase(serverUrl); + }); + + it('should return true when there are unread channels that are not muted', async () => { + const subscriptionNext = jest.fn(); + const notify_props = {mark_unread: 'all' as const}; + const result = observeMyChannelUnreads(database, teamId); + result.subscribe({next: subscriptionNext}); + + TestHelper.wait(waitTime); + + // Subscription always returns the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + subscriptionNext.mockClear(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should return false when all unread channels are muted', async () => { + const subscriptionNext = jest.fn(); + const notify_props = {mark_unread: 'mention' as const}; + const result = observeMyChannelUnreads(database, teamId); + result.subscribe({next: subscriptionNext}); + + TestHelper.wait(waitTime); + + // Subscription always returns the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + expect(subscriptionNext).not.toHaveBeenCalled(); + }); + + it('missing notify props are considered unmuted', async () => { + const subscriptionNext = jest.fn(); + const notify_props = {}; + const result = observeMyChannelUnreads(database, teamId); + result.subscribe({next: subscriptionNext}); + + TestHelper.wait(waitTime); + + // Subscription always returns the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + subscriptionNext.mockClear(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should not retrigger the subscription when there are changes in other teams', async () => { + const subscriptionNext = jest.fn(); + const otherTeamId = 'other_team_id'; + const notify_props = {mark_unread: 'all' as const}; + const result = observeMyChannelUnreads(database, teamId); + result.subscribe({next: subscriptionNext}); + + TestHelper.wait(waitTime); + + // Subscription always returns the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 20})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + expect(subscriptionNext).not.toHaveBeenCalled(); + + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: otherTeamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({channel_id: 'channel1', user_id: userId, notify_props, msg_count: 30})], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + TestHelper.wait(waitTime); + + expect(subscriptionNext).not.toHaveBeenCalled(); + }); +}); diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 32e28b700..09e0d221d 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -5,7 +5,7 @@ import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb'; import {of as of$, Observable} from 'rxjs'; -import {map as map$, switchMap, distinctUntilChanged} from 'rxjs/operators'; +import {map as map$, switchMap, distinctUntilChanged, combineLatestWith} from 'rxjs/operators'; import {General, Permissions} from '@constants'; import {MM_TABLES} from '@constants/database'; @@ -536,6 +536,19 @@ export function observeMyChannelMentionCount(database: Database, teamId?: string ); } +export function observeMyChannelUnreads(database: Database, teamId: string) { + const myChannels = queryMyChannelsByTeam(database, teamId).observeWithColumns(['is_unread']); + const notifyProps = observeAllMyChannelNotifyProps(database); + return myChannels.pipe( + combineLatestWith(notifyProps), + switchMap(([mycs, notify]) => of$(mycs.reduce((acc, v) => { + const isMuted = notify?.[v.id]?.mark_unread === 'mention'; + return acc || (v.isUnread && !isMuted); + }, false))), + distinctUntilChanged(), + ); +} + export function queryMyRecentChannels(database: Database, take: number) { const count: Q.Clause[] = []; diff --git a/app/queries/servers/team.test.ts b/app/queries/servers/team.test.ts index f52381420..dd8afc5c9 100644 --- a/app/queries/servers/team.test.ts +++ b/app/queries/servers/team.test.ts @@ -3,10 +3,13 @@ /* eslint-disable max-lines */ -import {Preferences, Screens} from '@constants'; +import {processReceivedThreads} from '@actions/local/thread'; +import {Config, Preferences, Screens} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import TestHelper from '@test/test_helper'; +import {prepareAllMyChannels} from './channel'; import {getTeamHistory} from './system'; import { getCurrentTeam, @@ -37,6 +40,7 @@ import { prepareMyTeams, queryTeamByName, getDefaultTeamId, + observeIsTeamUnread, observeSortedJoinedTeams, } from './team'; @@ -1023,6 +1027,189 @@ describe('Team Queries', () => { }, 1500); }); + describe('observeIsTeamUnread', () => { + const userId = 'user_id'; + const waitTime = 50; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: userId}], + prepareRecordsOnly: false, + }); + await operator.handleConfigs({ + configs: [ + {id: 'CollapsedThreads', value: Config.ALWAYS_ON}, + {id: 'Version', value: '7.6.0'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return true when there are channel unreads', async () => { + const subscriptionNext = jest.fn(); + const notify_props = {mark_unread: 'all' as const}; + const result = observeIsTeamUnread(database, teamId); + + result.subscribe({next: subscriptionNext}); + await TestHelper.wait(waitTime); + + // The subscription always return the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + // Setup initial state - channel with no unreads + let models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({ + channel_id: 'channel1', + user_id: userId, + notify_props, + msg_count: 20, + })], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + await TestHelper.wait(waitTime); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + // Update channel with new messages + models = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: 'channel1', team_id: teamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({ + channel_id: 'channel1', + user_id: userId, + notify_props, + msg_count: 20, + })], + false, + )))).flat(); + await operator.batchRecords(models, 'test'); + await TestHelper.wait(waitTime); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + }); + + it('should return true when there are thread unreads', async () => { + const subscriptionNext = jest.fn(); + const channelId = 'channel1'; + const threadId = 'thread1'; + + const result = observeIsTeamUnread(database, teamId); + + result.subscribe({next: subscriptionNext}); + await TestHelper.wait(waitTime); + + // The subscription always return the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + const channelModels = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: channelId, team_id: teamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({ + channel_id: channelId, + user_id: userId, + msg_count: 20, + })], + false, + )))).flat(); + const threadModels = await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 0, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, true); + await operator.batchRecords([...channelModels, ...threadModels.models!], 'test'); + + // No change + expect(subscriptionNext).not.toHaveBeenCalled(); + + await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 1, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, false); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + }); + + it('changes in other teams should not trigger the subscription', async () => { + const subscriptionNext = jest.fn(); + const channelId = 'channel1'; + const threadId = 'thread1'; + const otherTeamId = 'other_team'; + + const result = observeIsTeamUnread(database, teamId); + + result.subscribe({next: subscriptionNext}); + await TestHelper.wait(waitTime); + + // The subscription always return the first value + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + let channelModels = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: channelId, team_id: otherTeamId, total_msg_count: 20})], + [TestHelper.fakeMyChannel({ + channel_id: channelId, + user_id: userId, + msg_count: 20, + })], + false, + )))).flat(); + let threadModels = await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 0, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, true); + await operator.batchRecords([...channelModels, ...threadModels.models!], 'test'); + + await TestHelper.wait(waitTime); + expect(subscriptionNext).not.toHaveBeenCalled(); + + channelModels = (await Promise.all((await prepareAllMyChannels( + operator, + [TestHelper.fakeChannel({id: channelId, team_id: otherTeamId, total_msg_count: 30})], + [TestHelper.fakeMyChannel({ + channel_id: channelId, + user_id: userId, + msg_count: 20, + })], + false, + )))).flat(); + threadModels = await processReceivedThreads(serverUrl, [TestHelper.fakeThread({ + id: threadId, + reply_count: 1, + unread_replies: 1, + post: TestHelper.fakePost({id: threadId, channel_id: channelId}), + is_following: true, + })], teamId, true); + await operator.batchRecords([...channelModels, ...threadModels.models!], 'test'); + + await TestHelper.wait(waitTime); + expect(subscriptionNext).not.toHaveBeenCalled(); + }); + }); + describe('observeSortedJoinedTeams', () => { it('should return teams sorted by preference order', (done) => { const teamIds = ['team1', 'team2', 'team3']; diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index 50d47e361..ab398fe1c 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -11,10 +11,10 @@ import {selectDefaultTeam} from '@helpers/api/team'; import {DEFAULT_LOCALE} from '@i18n'; import {prepareDeleteCategory} from './categories'; -import {prepareDeleteChannel, getDefaultChannelForTeam, observeMyChannelMentionCount} from './channel'; +import {prepareDeleteChannel, getDefaultChannelForTeam, observeMyChannelMentionCount, observeMyChannelUnreads} from './channel'; import {queryPreferencesByCategoryAndName} from './preference'; import {patchTeamHistory, getConfig, getTeamHistory, observeCurrentTeamId, getCurrentTeamId} from './system'; -import {observeThreadMentionCount} from './thread'; +import {observeThreadMentionCount, observeUnreadsAndMentionsInTeam} from './thread'; import {getCurrentUser} from './user'; import type {MyChannelModel} from '@database/models/server'; @@ -418,6 +418,19 @@ export function observeMentionCount(database: Database, teamId?: string, include ); } +export function observeIsTeamUnread(database: Database, teamId: string): Observable { + const channelUnreads = observeMyChannelUnreads(database, teamId); + const threadsUnreadsAndMentions = observeUnreadsAndMentionsInTeam(database, teamId); + + return channelUnreads.pipe( + combineLatestWith(threadsUnreadsAndMentions), + map$(([channels, threads]) => { + return channels || threads.unreads; + }), + distinctUntilChanged(), + ); +} + export function observeSortedJoinedTeams(database: Database) { const myTeams = queryMyTeams(database).observe(); const teams = queryJoinedTeams(database).observe(); diff --git a/test/api_mocks/file.ts b/test/api_mocks/file.ts deleted file mode 100644 index 528879a5a..000000000 --- a/test/api_mocks/file.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -export function mockFileInfo(overwrite: Partial = {}): FileInfo { - return { - id: '1', - localPath: 'path/to/image1', - uri: '', - has_preview_image: true, - extension: 'png', - height: 100, - width: 100, - mime_type: 'image/png', - name: 'image1', - size: 100, - user_id: '1', - ...overwrite, - }; -} diff --git a/test/test_helper.ts b/test/test_helper.ts index 065cfafc4..5ec2735ea 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -220,10 +220,10 @@ class TestHelperSingleton { }; }; - fakeChannel = (teamId: string): Channel => { + fakeChannel = (overwrite: Partial): Channel => { return { name: 'channel', - team_id: teamId, + team_id: this.generateId(), // @to-do: Make tests more detriministic; // https://jestjs.io/docs/snapshot-testing#2-tests-should-be-deterministic @@ -243,12 +243,13 @@ class TestHelperSingleton { create_at: 1507840900004, update_at: 1507840900004, id: '', + ...overwrite, }; }; fakeChannelWithId = (teamId: string): Channel => { return { - ...this.fakeChannel(teamId), + ...this.fakeChannel({team_id: teamId}), id: this.generateId(), }; }; @@ -266,11 +267,11 @@ class TestHelperSingleton { }; }; - fakeChannelMember = (userId: string, channelId: string): ChannelMembership => { + fakeChannelMember = (overwrite: Partial): ChannelMembership => { return { - id: channelId, - user_id: userId, - channel_id: channelId, + id: this.generateId(), + user_id: this.generateId(), + channel_id: this.generateId(), notify_props: {}, roles: 'system_user', msg_count: 0, @@ -279,13 +280,15 @@ class TestHelperSingleton { scheme_admin: false, last_viewed_at: 0, last_update_at: 0, + ...overwrite, }; }; - fakeMyChannel = (userId: string, channelId: string): ChannelMembership => { + fakeMyChannel = (overwrite: Partial): ChannelMembership => { return { - id: channelId, - channel_id: channelId, + id: this.generateId(), + user_id: this.generateId(), + channel_id: this.generateId(), last_post_at: 0, last_viewed_at: 0, manually_unread: false, @@ -293,15 +296,15 @@ class TestHelperSingleton { msg_count: 0, is_unread: false, roles: '', - user_id: userId, notify_props: {}, last_update_at: 0, + ...overwrite, }; }; fakeMyChannelSettings = (userId: string, channelId: string): ChannelMembership => { return { - ...this.fakeMyChannel(userId, channelId), + ...this.fakeMyChannel({user_id: userId, channel_id: channelId}), notify_props: { desktop: 'default', email: 'default', @@ -316,12 +319,12 @@ class TestHelperSingleton { return 'success' + this.generateId() + '@simulator.amazonses.com'; }; - fakePost = (channelId: string, userId?: string): Post => { + fakePost = (overwrite: Partial): Post => { const time = Date.now(); return { id: this.generateId(), - channel_id: channelId, + channel_id: this.generateId(), create_at: time, update_at: time, message: `Unit Test ${this.generateId()}`, @@ -336,21 +339,40 @@ class TestHelperSingleton { props: {}, reply_count: 0, root_id: '', - user_id: userId || this.generateId(), + user_id: this.generateId(), + ...overwrite, }; }; fakePostWithId = (channelId: string) => { return { - ...this.fakePost(channelId), - id: this.generateId(), - create_at: 1507840900004, - update_at: 1507840900004, - delete_at: 0, + ...this.fakePost({ + channel_id: channelId, + id: this.generateId(), + create_at: 1507840900004, + update_at: 1507840900004, + delete_at: 0, + }), }; }; - fakeTeam = (): Team => { + fakeThread = (overwrite: Partial): Thread => { + const id = overwrite.id ?? this.generateId(); + return { + id, + delete_at: 0, + participants: [], + post: this.fakePost({id}), + last_reply_at: 0, + last_viewed_at: 0, + reply_count: 0, + unread_mentions: 0, + unread_replies: 0, + ...overwrite, + }; + }; + + fakeTeam = (overwrite?: Partial): Team => { const name = this.generateId(); let inviteId = this.generateId(); if (inviteId.length > 32) { @@ -374,6 +396,7 @@ class TestHelperSingleton { create_at: 0, delete_at: 0, update_at: 0, + ...overwrite, }; }; @@ -526,6 +549,23 @@ class TestHelperSingleton { }; }; + fakeFileInfo = (overwrite: Partial = {}): FileInfo => { + return { + id: '1', + localPath: 'path/to/image1', + uri: '', + has_preview_image: true, + extension: 'png', + height: 100, + width: 100, + mime_type: 'image/png', + name: 'image1', + size: 100, + user_id: '1', + ...overwrite, + }; + }; + mockLogin = () => { nock(this.basicClient?.getBaseRoute() || ''). post('/users/login'). @@ -556,8 +596,8 @@ class TestHelperSingleton { this.basicCategory = this.fakeCategoryWithId(this.basicTeam.id); this.basicChannel = this.fakeChannelWithId(this.basicTeam.id); this.basicCategoryChannel = this.fakeCategoryChannelWithId(this.basicTeam.id, this.basicCategory.id, this.basicChannel.id); - this.basicChannelMember = this.fakeChannelMember(this.basicUser.id, this.basicChannel.id); - this.basicMyChannel = this.fakeMyChannel(this.basicUser.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}); this.basicMyChannelSettings = this.fakeMyChannelSettings(this.basicUser.id, this.basicChannel.id); this.basicPost = {...this.fakePostWithId(this.basicChannel.id), create_at: 1507841118796} as Post; this.basicRoles = {