Fix team sidebar not showing thread unreads (#8624)

* Fix team sidebar not showing thread unreads

* Fix tests

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García 2025-03-18 13:33:15 +01:00 committed by GitHub
parent 59252d98fb
commit b475f2bedb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 793 additions and 169 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof Files> {
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(
<Files
@ -388,8 +388,8 @@ describe('Files', () => {
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) => {

View file

@ -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(<EnhancedTeamItem myTeam={myTeam}/>, {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(<EnhancedTeamItem myTeam={myTeam}/>, {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(<EnhancedTeamItem myTeam={myTeam}/>, {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);
});
});

View file

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

View file

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

View file

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

View file

@ -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[] = [];

View file

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

View file

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

View file

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

View file

@ -220,10 +220,10 @@ class TestHelperSingleton {
};
};
fakeChannel = (teamId: string): Channel => {
fakeChannel = (overwrite: Partial<Channel>): 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>): 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>): 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>): 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>): 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>): 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> = {}): 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 = {