Login improvement (#8306)

* refactor to improve requests on login

* include in team sidebar order by display name teams not present in the order preference

* Fix iOS reload while developing

* fix code causing tests to fail

* feedback review

* update network-client
This commit is contained in:
Elias Nahum 2024-11-13 08:06:12 +08:00 committed by GitHub
parent f6bc8ae50b
commit ea2cb45a18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 364 additions and 118 deletions

View file

@ -105,13 +105,13 @@ const mockClient = {
getUser: jest.fn((userId: string) => ({...user, id: userId})),
getMyChannels: jest.fn((teamId: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: teamId}])),
getMyChannelMembers: jest.fn(() => ([{id: user.id + '-' + channelId, user_id: user.id, channel_id: channelId, roles: ''}])),
getCategories: jest.fn((userId: string, teamId: string) => ({categories: [{id: 'categoryid', channel_id: [channelId], team_id: teamId}], order: ['categoryid']})),
getCategories: jest.fn((userId: string, teamId: string) => ({categories: [{id: 'categoryid', channel_ids: [channelId], team_id: teamId}], order: ['categoryid']})),
viewMyChannel: jest.fn(),
getTeamByName: jest.fn((teamName: string) => ({id: teamId, name: teamName})),
getTeam: jest.fn((id: string) => ({id, name: 'teamname'})),
addToTeam: jest.fn((teamId: string, userId: string) => ({id: userId + '-' + teamId, user_id: userId, team_id: teamId, roles: ''})),
getUserByUsername: jest.fn((username: string) => ({...user, id: 'userid2', username})),
createDirectChannel: jest.fn((userId1: string, userId2: string) => ({id: userId1 + '__' + userId2, team_id: '', type: 'D', display_name: 'displayname'})),
createDirectChannel: jest.fn((userIds: string[]) => ({id: userIds[0] + '__' + userIds[1], team_id: '', type: 'D', display_name: 'displayname'})),
getChannels: jest.fn((teamId: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: teamId}])),
getArchivedChannels: jest.fn((teamId: string) => ([{id: channelId + 'old', name: 'channel1old', creatorId: user.id, team_id: teamId, delete_at: 1}])),
createGroupChannel: jest.fn(() => ({id: 'groupid', team_id: '', type: 'G', display_name: 'displayname'})),
@ -129,6 +129,11 @@ const mockClient = {
convertChannelToPrivate: jest.fn(),
getGroupMessageMembersCommonTeams: jest.fn(() => ({id: teamId, name: 'teamname'})),
convertGroupMessageToPrivateChannel: jest.fn((channelId: string) => ({id: channelId, name: 'channel1', creatorId: user.id, type: 'P'})),
getPosts: jest.fn(() => ({
order: [],
posts: [],
previousPostId: '',
})),
};
const teamId = 'teamid1';

View file

@ -45,6 +45,7 @@ import type ChannelModel from '@typings/database/models/servers/channel';
import type {IntlShape} from 'react-intl';
export type MyChannelsRequest = {
teamId?: string;
categories?: CategoryWithChannels[];
channels?: Channel[];
memberships?: ChannelMembership[];
@ -441,7 +442,7 @@ export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string,
setTeamLoading(serverUrl, false);
}
return {channels, memberships, categories};
return {channels, memberships, categories, teamId};
} catch (error) {
logDebug('error on fetchMyChannelsForTeam', getFullErrorMessage(error));
if (!fetchOnly) {

View file

@ -10,7 +10,7 @@ import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, type MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {fetchMyTeams, fetchTeamsChannelsThreadsAndUnreadPosts, handleKickFromTeam, type MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
import {fetchMe, type MyUserRequest, updateAllUsersSince, autoUpdateTimezone} from '@actions/remote/user';
import {General, Preferences, Screens} from '@constants';
@ -66,9 +66,6 @@ export type EntryResponse = {
error: unknown;
}
const FETCH_MISSING_DM_TIMEOUT = 2500;
export const FETCH_UNREADS_TIMEOUT = 2500;
export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0): Promise<EntryResponse> => {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const result = entryRest(serverUrl, teamId, channelId, since);
@ -311,8 +308,7 @@ const fetchAlternateTeamData = async (
let initialTeamId = '';
let chData;
for (const teamId of availableTeamIds) {
// eslint-disable-next-line no-await-in-loop
for await (const teamId of availableTeamIds) {
chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly, false, isCRTEnabled);
const chError = chData.error;
if (isErrorWithStatusCode(chError) && chError.status_code === 403) {
@ -371,49 +367,56 @@ async function entryInitialChannelId(database: Database, requestedChannelId = ''
async function restDeferredAppEntryActions(
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense | undefined, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
initialTeamId?: string, initialChannelId?: string) {
setTimeout(async () => {
if (chData?.channels?.length && chData.memberships?.length) {
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
}, FETCH_UNREADS_TIMEOUT);
initialTeamId?: string, initialChannelId?: string,
) {
const isCRTEnabled = (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) || false;
const directChannels = chData?.channels?.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
// sidebar DM & GM profiles
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
updateAllUsersSince(serverUrl, since);
updateCanJoinTeams(serverUrl);
// defer fetch channels and unread posts for other teams
if (teamData.teams?.length && teamData.memberships?.length) {
fetchTeamsChannelsAndUnreadPosts(serverUrl, since, teamData.teams, teamData.memberships, initialTeamId);
}
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) {
if (initialTeamId) {
await syncTeamThreads(serverUrl, initialTeamId);
}
if (teamData.teams?.length) {
for await (const team of teamData.teams) {
if (team.id !== initialTeamId) {
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, team.id);
}
setTimeout(async () => {
if (chData?.channels?.length && chData.memberships?.length && initialTeamId) {
if (isCRTEnabled && initialTeamId) {
await syncTeamThreads(serverUrl, initialTeamId);
}
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
}
updateCanJoinTeams(serverUrl);
await updateAllUsersSince(serverUrl, since);
if (teamData.teams?.length && teamData.memberships?.length) {
const teamsOrder = preferences?.find((p) => p.category === Preferences.CATEGORIES.TEAMS_ORDER);
const sortedTeamIds = new Set(teamsOrder?.value.split(','));
const membershipSet = new Set(teamData.memberships.map((m) => m.team_id));
const teamMap = new Map(teamData.teams.map((t) => [t.id, t]));
let myTeams: Team[];
if (sortedTeamIds.size) {
const mySortedTeams = [...sortedTeamIds].
filter((id) => membershipSet.has(id) && teamMap.has(id)).
map((id) => teamMap.get(id)!);
const extraTeams = [...membershipSet].
filter((id) => !sortedTeamIds.has(id) && teamMap.get(id)).
map((id) => teamMap.get(id)!).
sort((a, b) => a.display_name.toLocaleLowerCase().localeCompare(b.display_name.toLocaleLowerCase()));
myTeams = [...mySortedTeams, ...extraTeams];
} else {
myTeams = teamData.teams.
sort((a, b) => a.display_name.toLocaleLowerCase().localeCompare(b.display_name.toLocaleLowerCase()));
}
fetchTeamsChannelsThreadsAndUnreadPosts(serverUrl, since, myTeams, isCRTEnabled);
}
});
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId);
// defer sidebar DM & GM profiles
setTimeout(async () => {
const directChannels = chData?.channels?.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
}, FETCH_MISSING_DM_TIMEOUT);
}
export const setExtraSessionProps = async (serverUrl: string) => {

View file

@ -4,6 +4,8 @@
/* eslint-disable max-lines */
import {chunk} from 'lodash';
import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel';
import {addPostAcknowledgement, removePost, removePostAcknowledgement, storePostsForChannel} from '@actions/local/post';
import {addRecentReaction} from '@actions/local/reactions';
@ -40,6 +42,13 @@ type PostsRequest = {
previousPostId?: string;
}
export type PostsForChannel = PostsRequest & {
actionType?: string;
authors?: UserProfile[];
channelId?: string;
error?: unknown;
};
type PostsObjectsRequest = {
error?: unknown;
order?: string[];
@ -275,7 +284,7 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => {
return {};
};
export async function fetchPostsForChannel(serverUrl: string, channelId: string, fetchOnly = false) {
export async function fetchPostsForChannel(serverUrl: string, channelId: string, fetchOnly = false): Promise<PostsForChannel> {
try {
if (!fetchOnly) {
EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId);
@ -312,7 +321,7 @@ export async function fetchPostsForChannel(serverUrl: string, channelId: string,
}
}
return {posts: data.posts, order: data.order, authors, actionType, previousPostId: data.previousPostId};
return {posts: data.posts, order: data.order, authors, actionType, previousPostId: data.previousPostId, channelId};
} catch (error) {
logDebug('error on fetchPostsForChannel', getFullErrorMessage(error));
return {error};
@ -323,15 +332,34 @@ export async function fetchPostsForChannel(serverUrl: string, channelId: string,
}
}
export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string) => {
const promises = [];
export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string, fetchOnly = false): Promise<PostsForChannel[]> => {
const membersMap = new Map<string, ChannelMembership>();
for (const member of memberships) {
const channel = channels.find((c) => c.id === member.channel_id);
if (channel && !channel.delete_at && (channel.total_msg_count - member.msg_count) > 0 && channel.id !== excludeChannelId) {
promises.push(fetchPostsForChannel(serverUrl, channel.id));
}
membersMap.set(member.channel_id, member);
}
return Promise.all(promises);
const unreadChannelIds = channels.reduce<string[]>((result, channel) => {
const member = membersMap.get(channel.id);
if (member && !channel.delete_at && (channel.total_msg_count - member.msg_count) > 0 && channel.id !== excludeChannelId) {
result.push(channel.id);
}
return result;
}, []);
const postsForChannel: PostsForChannel[] = [];
// process 10 unread channels at a time
const chunks = chunk(unreadChannelIds, 10);
for await (const channelIds of chunks) {
const promises = [];
for (const channelId of channelIds) {
promises.push(fetchPostsForChannel(serverUrl, channelId, fetchOnly));
}
const results = await Promise.all(promises);
postsForChannel.push(...results);
}
return postsForChannel;
};
export async function fetchPosts(serverUrl: string, channelId: string, page = 0, perPage = General.POST_CHUNK_SIZE, fetchOnly = false): Promise<PostsRequest> {

View file

@ -18,7 +18,6 @@ import {
fetchAllTeams,
fetchTeamsForComponent,
updateCanJoinTeams,
fetchTeamsChannelsAndUnreadPosts,
fetchTeamByName,
removeCurrentUserFromTeam,
removeUserFromTeam,
@ -26,6 +25,7 @@ import {
handleKickFromTeam,
getTeamMembersByIds,
buildTeamIconUrl,
fetchTeamsChannelsThreadsAndUnreadPosts,
} from './team';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -81,12 +81,38 @@ const mockClient = {
getMyTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])),
getMyTeamMembers: jest.fn(() => ([{id: 'userid1-' + teamId, user_id: 'userid1', team_id: teamId, roles: ''}])),
getTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])),
getMyChannels: jest.fn((id: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: id}])),
getMyChannelMembers: jest.fn(() => ([{id: user.id + '-' + channelId, user_id: user.id, channel_id: channelId, roles: ''}])),
getCategories: jest.fn((userId: string, id: string) => ({categories: [{id: 'categoryid', channel_id: [channelId], team_id: id}], order: ['categoryid']})),
getMyChannels: jest.fn((id: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: id, total_msg_count: 1}])),
getMyChannelMembers: jest.fn(() => ([{id: user.id + '-' + channelId, user_id: user.id, channel_id: channelId, roles: '', msg_count: 0}])),
getCategories: jest.fn((userId: string, id: string) => ({categories: [{id: 'categoryid', channel_ids: [channelId], team_id: id}], order: ['categoryid']})),
getTeamByName: jest.fn((name: string) => ({id: teamId, name})),
removeFromTeam: jest.fn(),
getTeamMembersByIds: jest.fn((id: string, userIds: string[]) => (userIds.map((userId) => ({id: userId + '-' + id, user_id: userId, team_id: id, roles: ''})))),
getPosts: jest.fn(() => ({
order: ['yocj9xgkh78exk1uhx9yny1zxy', 'ad6yoisgh385fy7rkph49zpfqa', 'o5qqa4ntdigp7rbnf75f8hgeaw'],
posts: [{
channel_id: channelId,
create_at: 1726107604522,
delete_at: 0,
edit_at: 0,
hashtags: '',
id: 'yocj9xgkh78exk1uhx9yny1zxy',
is_pinned: false,
last_reply_at: 0,
message: 'Message ead863',
metadata: {},
original_id: '',
participants: null,
pending_post_id: '',
props: {},
remote_id: '',
reply_count: 0,
root_id: '',
type: '',
update_at: 1726107604522,
user_id: 'k479wsypafgjddz8cerqx4m1ha',
}],
previousPostId: '',
})),
};
beforeAll(() => {
@ -245,16 +271,25 @@ describe('teams', () => {
expect(result).toBeDefined();
});
it('fetchTeamsChannelsAndUnreadPosts - handle not found database', async () => {
const result = await fetchTeamsChannelsAndUnreadPosts('foo', 0, [], []) as {error: unknown};
it('fetchTeamsChannelsThreadsAndUnreadPosts - handle not found database', async () => {
const result = await fetchTeamsChannelsThreadsAndUnreadPosts('foo', 0, []) as {error: unknown};
expect(result?.error).toBeDefined();
});
it('fetchTeamsChannelsAndUnreadPosts - base case', async () => {
const result = await fetchTeamsChannelsAndUnreadPosts(serverUrl, 0, [team], [{team_id: teamId, user_id: 'userid1'} as TeamMembership]);
it('fetchTeamsChannelsThreadsAndUnreadPosts - base case', async () => {
const result = await fetchTeamsChannelsThreadsAndUnreadPosts(
serverUrl, 0,
[team], true, false);
expect(result).toBeDefined();
});
it('fetchTeamsChannelsThreadsAndUnreadPosts - fetch only case', async () => {
const result = await fetchTeamsChannelsThreadsAndUnreadPosts(
serverUrl, 0,
[team], true, true);
expect(result.models).toBeDefined();
});
it('fetchTeamByName - handle not found database', async () => {
const result = await fetchTeamByName('foo', '') as {error: unknown};
expect(result?.error).toBeDefined();

View file

@ -1,12 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {chunk} from 'lodash';
import {DeviceEventEmitter} from 'react-native';
import {storeCategories} from '@actions/local/category';
import {storeMyChannelsForTeam} from '@actions/local/channel';
import {storePostsForChannel} from '@actions/local/post';
import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team';
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import {Events} from '@constants';
import DatabaseManager from '@database/manager';
import {removeDuplicatesModels} from '@helpers/database';
import NetworkManager from '@managers/network_manager';
import {getActiveServerUrl} from '@queries/app/servers';
import {prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories';
@ -22,9 +27,10 @@ import {logDebug} from '@utils/log';
import {fetchMyChannelsForTeam, switchToChannelById} from './channel';
import {fetchGroupsForTeamIfConstrained} from './groups';
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post';
import {fetchPostsForChannel, fetchPostsForUnreadChannels, type PostsForChannel} from './post';
import {fetchRolesIfNeeded} from './role';
import {forceLogoutIfNecessary} from './session';
import {syncThreadsIfNeeded} from './thread';
import type {Client} from '@client/rest';
import type {Model} from '@nozbe/watermelondb';
@ -312,24 +318,85 @@ export const updateCanJoinTeams = async (serverUrl: string) => {
}
};
export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, since: number, teams: Team[], memberships: TeamMembership[], excludeTeamId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
export const fetchTeamsChannelsThreadsAndUnreadPosts = async (
serverUrl: string, since: number, teams: Team[],
isCRTEnabled?: boolean, fetchOnly = false,
) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const result: Model[] = [];
const membershipSet = new Set(memberships.map((m) => m.team_id));
const myTeams = teams.filter((t) => membershipSet.has(t.id) && t.id !== excludeTeamId);
// process up to 15 teams at a time
const chunks = chunk(teams, 15);
for await (const myTeams of chunks) {
const promises = [];
for (const team of myTeams) {
promises.push(fetchMyChannelsForTeam(serverUrl, team.id, true, since, true, true, isCRTEnabled));
}
for await (const team of myTeams) {
const {channels, memberships: members} = await fetchMyChannelsForTeam(serverUrl, team.id, true, since, false, true);
const results = await Promise.all(promises);
const models: Model[] = [];
const channels: Channel[] = [];
const members: ChannelMembership[] = [];
if (channels?.length && members?.length) {
fetchPostsForUnreadChannels(serverUrl, channels, members);
for await (const r of results) {
if (!r.error && r.teamId && r.categories && r.channels && r.memberships) {
const {models: chModels} = await storeMyChannelsForTeam(serverUrl, r.teamId, r.channels, r.memberships, true, isCRTEnabled);
const {models: catModels} = await storeCategories(serverUrl, r.categories, true, true); // Re-sync
if (chModels?.length) {
models.push(...chModels);
}
if (catModels?.length) {
models.push(...catModels);
}
channels.push(...r.channels);
members.push(...r.memberships);
}
}
const unreadPromise = fetchPostsForUnreadChannels(serverUrl, channels, members, undefined, true);
const threadsPromise = syncThreadsIfNeeded(serverUrl, isCRTEnabled ?? false, myTeams, true);
const postPromises: [Promise<PostsForChannel[]>, Promise<{models?: Model[]; error?: unknown}>] = [
unreadPromise,
threadsPromise,
];
const [unreadPosts, threads] = await Promise.all(postPromises);
for await (const data of unreadPosts) {
if (data.channelId && data.posts?.length && data.order?.length && data.actionType) {
const {models: prepared} = await storePostsForChannel(
serverUrl, data.channelId,
data.posts, data.order, data.previousPostId ?? '',
data.actionType, data.authors || [], true,
);
if (prepared?.length) {
models.push(...prepared);
}
}
}
if (threads.models) {
models.push(...threads.models);
}
if (!fetchOnly && models.length) {
await operator.batchRecords(removeDuplicatesModels(models), 'fetchTeamsChannelsThreadsAndUnreadPosts');
}
if (models.length) {
result.push(...models);
}
}
}
return {};
return {error: undefined, models: result};
} catch (error) {
logDebug('error on fetchTeamsChannelsThreadsAndUnreadPosts', getFullErrorMessage(error));
return {error};
}
};
export async function fetchTeamByName(serverUrl: string, teamName: string, fetchOnly = false) {

View file

@ -19,6 +19,7 @@ import {
syncTeamThreads,
loadEarlierThreads,
fetchAndSwitchToThread,
syncThreadsIfNeeded,
} from './thread';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -150,7 +151,7 @@ describe('get threads', () => {
expect(result).toBeDefined();
expect(result.error).toBeFalsy();
expect(result.models).toBeDefined();
expect(result.models?.length).toBe(5); // 1 thread, 1 thread participant, 1 read thread in team, 1 unread thread in team, 1 team thread sync
expect(result.models?.length).toBe(4); // 1 thread, 1 thread participant, 1 latest thread in team, 1 team thread sync (the unread thread is actually the latest so it the duplicate is removed)
// Sync again after first sync
const result2 = await syncTeamThreads(serverUrl, team.id);
@ -160,6 +161,23 @@ describe('get threads', () => {
expect(result2.models?.length).toBe(1); // 1 team thread sync
});
it('syncThreadsIfNeeded - handle error', async () => {
const result = await syncThreadsIfNeeded('foo', true, []);
expect(result).toBeDefined();
expect(result.error).toBeTruthy();
});
it('syncThreadsIfNeeded - base case', async () => {
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
const result = await syncThreadsIfNeeded(serverUrl, true, [team]);
expect(result).toBeDefined();
expect(result.error).toBeFalsy();
expect(result.models).toBeDefined();
expect(result.models?.length).toBe(4); // 1 thread, 1 thread participant, 1 latest thread in team, 1 team thread sync (the unread thread is actually the latest so it the duplicate is removed)
});
it('loadEarlierThreads - handle error', async () => {
const result = await loadEarlierThreads('foo', '', '');
expect(result).toBeDefined();

View file

@ -5,6 +5,7 @@ import {markTeamThreadsAsRead, markThreadAsViewed, processReceivedThreads, switc
import {fetchPostThread} from '@actions/remote/post';
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import {removeDuplicatesModels} from '@helpers/database';
import PushNotifications from '@init/push_notifications';
import AppsManager from '@managers/apps_manager';
import NetworkManager from '@managers/network_manager';
@ -13,7 +14,8 @@ import {getConfigValue, getCurrentChannelId, getCurrentTeamId} from '@queries/se
import {getIsCRTEnabled, getThreadById, getTeamThreadsSyncData} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {isMinimumServerVersion} from '@utils/helpers';
import {logDebug, logError} from '@utils/log';
import {showThreadFollowingSnackbar} from '@utils/snack_bar';
import {getThreadsListEdges} from '@utils/thread';
@ -30,6 +32,7 @@ type FetchThreadsOptions = {
unread?: boolean;
since?: number;
totalsOnly?: boolean;
excludeDirect?: boolean;
};
enum Direction {
@ -238,10 +241,10 @@ export const fetchThreads = async (
let currentPage = 0;
const fetchThreadsFunc = async (opts: FetchThreadsOptions) => {
const {before, after, perPage = General.CRT_CHUNK_SIZE, deleted, unread, since} = opts;
const {before, after, perPage = General.CRT_CHUNK_SIZE, deleted, unread, since, excludeDirect = false} = opts;
currentPage++;
const {threads} = await client.getThreads(currentUser.id, teamId, before, after, perPage, deleted, unread, since, false, version);
const {threads} = await client.getThreads(currentUser.id, teamId, before, after, perPage, deleted, unread, since, false, version, excludeDirect);
if (threads.length) {
// Mark all fetched threads as following
for (const thread of threads) {
@ -277,7 +280,50 @@ export const fetchThreads = async (
return {error: false, threads: threadsData};
};
export const syncTeamThreads = async (serverUrl: string, teamId: string, prepareRecordsOnly = false) => {
export const syncThreadsIfNeeded = async (serverUrl: string, isCRTEnabled: boolean, teams?: Team[], fetchOnly = false) => {
try {
if (!isCRTEnabled) {
return {models: []};
}
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const promises = [];
const models: Model[][] = [];
// this is to keep backwards compatibility with servers that send the
// threads for DM / GM regardless for every team
const version = await getConfigValue(database, 'Version');
const hasThreadExclusions = isMinimumServerVersion(version, 10, 2, 0);
if (teams?.length) {
for (const team of teams) {
promises.push(syncTeamThreads(serverUrl, team.id, true, true));
}
}
if (promises.length) {
const results = await Promise.all(promises);
for (const r of results) {
if (r.models?.length) {
models.push(r.models);
}
}
}
const flat = models.flat();
if (!fetchOnly && flat.length) {
const uniqueArray = hasThreadExclusions ? flat : removeDuplicatesModels(flat);
await operator.batchRecords(uniqueArray, 'syncThreadsIfNeeded');
}
return {models: flat};
} catch (error) {
logError('syncThreadsIfNeeded: Error', error);
return {error, models: undefined};
}
};
export const syncTeamThreads = async (serverUrl: string, teamId: string, excludeDirect = false, fetchOnly = false) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const syncData = await getTeamThreadsSyncData(database, teamId);
@ -299,13 +345,13 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
fetchThreads(
serverUrl,
teamId,
{unread: true},
{unread: true, excludeDirect},
Direction.Down,
),
fetchThreads(
serverUrl,
teamId,
{},
{excludeDirect},
undefined,
1,
),
@ -313,6 +359,9 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
if (allUnreadThreads.error || latestThreads.error) {
return {error: allUnreadThreads.error || latestThreads.error};
}
const dedupe = new Set(latestThreads.threads?.map((t) => t.id));
if (latestThreads.threads?.length) {
// We are fetching the threads for the first time. We get "latest" and "earliest" values.
const {earliestThread, latestThread} = getThreadsListEdges(latestThreads.threads);
@ -322,13 +371,14 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
threads.push(...latestThreads.threads);
}
if (allUnreadThreads.threads?.length) {
threads.push(...allUnreadThreads.threads);
const unread = allUnreadThreads.threads.filter((u) => !dedupe.has(u.id));
threads.push(...unread);
}
} else {
const allNewThreads = await fetchThreads(
serverUrl,
teamId,
{deleted: true, since: syncData.latest + 1},
{deleted: true, since: syncData.latest + 1, excludeDirect},
);
if (allNewThreads.error) {
return {error: allNewThreads.error};
@ -361,7 +411,7 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
}
}
if (!prepareRecordsOnly && models?.length) {
if (!fetchOnly && models?.length) {
try {
await operator.batchRecords(models, 'syncTeamThreads');
} catch (err) {
@ -372,7 +422,6 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
}
}
}
return {error: false, models};
} catch (error) {
return {error};

View file

@ -314,11 +314,11 @@ export async function handleUserAddedToChannelEvent(serverUrl: string, msg: any)
}
const {posts, order, authors, actionType, previousPostId} = await fetchPostsForChannel(serverUrl, channelId, true);
if (posts?.length && order?.length) {
if (posts?.length && order?.length && actionType) {
const {models: prepared} = await storePostsForChannel(
serverUrl, channelId,
posts, order, previousPostId ?? '',
actionType, authors, true,
actionType, authors || [], true,
);
if (prepared?.length) {

View file

@ -8,7 +8,7 @@ import {PER_PAGE_DEFAULT} from './constants';
import type ClientBase from './base';
export interface ClientThreadsMix {
getThreads: (userId: string, teamId: string, before?: string, after?: string, pageSize?: number, deleted?: boolean, unread?: boolean, since?: number, totalsOnly?: boolean, serverVersion?: string) => Promise<GetUserThreadsResponse>;
getThreads: (userId: string, teamId: string, before?: string, after?: string, pageSize?: number, deleted?: boolean, unread?: boolean, since?: number, totalsOnly?: boolean, serverVersion?: string, excludeDirect?: boolean) => Promise<GetUserThreadsResponse>;
getThread: (userId: string, teamId: string, threadId: string, extended?: boolean) => Promise<any>;
markThreadAsRead: (userId: string, teamId: string, threadId: string, timestamp: number) => Promise<any>;
markThreadAsUnread: (userId: string, teamId: string, threadId: string, postId: string) => Promise<any>;
@ -17,7 +17,7 @@ export interface ClientThreadsMix {
}
const ClientThreads = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
getThreads = async (userId: string, teamId: string, before = '', after = '', pageSize = PER_PAGE_DEFAULT, deleted = false, unread = false, since = 0, totalsOnly = false, serverVersion = '') => {
getThreads = async (userId: string, teamId: string, before = '', after = '', pageSize = PER_PAGE_DEFAULT, deleted = false, unread = false, since = 0, totalsOnly = false, serverVersion = '', excludeDirect = false) => {
const queryStringObj: Record<string, any> = {
extended: 'true',
before,
@ -26,6 +26,7 @@ const ClientThreads = <TBase extends Constructor<ClientBase>>(superclass: TBase)
unread,
since,
totalsOnly,
excludeDirect,
};
if (serverVersion && isMinimumServerVersion(serverVersion, 6, 0)) {
queryStringObj.per_page = pageSize;

View file

@ -16,6 +16,11 @@ import TeamList from './team_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type MyTeamModel from '@typings/database/models/servers/my_team';
interface TeamWithLowerName {
myTeam: MyTeamModel;
lowerName?: string;
}
const withTeams = withObservables([], ({database}: WithDatabaseArgs) => {
const myTeams = queryMyTeams(database).observe();
const teamIds = queryJoinedTeams(database).observe().pipe(
@ -26,26 +31,41 @@ const withTeams = withObservables([], ({database}: WithDatabaseArgs) => {
switchMap((p) => (p.length ? of$(p[0].value.split(',')) : of$([]))),
);
const myOrderedTeams = combineLatest([myTeams, order, teamIds]).pipe(
map(([ts, o, tids]) => {
let ids: string[] = o;
if (!o.length) {
ids = tids.
sort((a, b) => a.displayName.toLocaleLowerCase().localeCompare(b.displayName.toLocaleLowerCase())).
map((t) => t.id);
map(([memberships, o, teams]) => {
const sortedTeamIds = new Set(o);
const membershipMap = new Map(memberships.map((m) => [m.id, m]));
if (sortedTeamIds.size) {
const mySortedTeams = [...sortedTeamIds].
filter((id) => membershipMap.has(id)).
map((id) => membershipMap.get(id)!);
const extraTeams = teams.
filter((t) => !sortedTeamIds.has(t.id) && membershipMap.has(t.id)).
map((t) => ({
myTeam: membershipMap.get(t.id)!,
lowerName: t.displayName.toLocaleLowerCase(),
} as TeamWithLowerName)).
sort((a, b) => a.lowerName!.localeCompare(b.lowerName!)).
map((t) => {
delete t.lowerName;
return t;
});
return [...mySortedTeams, ...extraTeams];
}
const teamMap = ts.reduce<{[x: string]: MyTeamModel}>((result, team) => {
result[team.id] = team;
return result;
}, {});
return ids.reduce<MyTeamModel[]>((result, id) => {
const t = teamMap[id];
if (t) {
result.push(t);
}
return result;
}, []);
return teams.
filter((t) => membershipMap.has(t.id)).
map((t) => ({
myTeam: membershipMap.get(t.id)!,
lowerName: t.displayName.toLocaleLowerCase(),
} as TeamWithLowerName)).
sort((a, b) => a.lowerName!.localeCompare(b.lowerName!)).
map((t) => {
delete t.lowerName;
return t.myTeam;
});
}),
);
return {

View file

@ -63,9 +63,12 @@ export const transformThreadRecord = ({action, database, value}: TransformerArgs
*/
export const transformThreadParticipantRecord = ({action, database, value}: TransformerArgs): Promise<ThreadParticipantModel> => {
const raw = value.raw as ThreadParticipant;
const record = value.record as ThreadParticipantModel;
const isCreateAction = action === OperationType.CREATE;
// id of participant comes from server response
const fieldsMapper = (participant: ThreadParticipantModel) => {
participant._raw.id = isCreateAction ? `${raw.thread_id}-${raw.id}` : record.id;
participant.threadId = raw.thread_id;
participant.userId = raw.id;
};
@ -81,8 +84,11 @@ export const transformThreadParticipantRecord = ({action, database, value}: Tran
export const transformThreadInTeamRecord = ({action, database, value}: TransformerArgs): Promise<ThreadInTeamModel> => {
const raw = value.raw as ThreadInTeam;
const record = value.record as ThreadInTeamModel;
const isCreateAction = action === OperationType.CREATE;
const fieldsMapper = (threadInTeam: ThreadInTeamModel) => {
threadInTeam._raw.id = isCreateAction ? `${raw.thread_id}-${raw.team_id}` : record.id;
threadInTeam.threadId = raw.thread_id;
threadInTeam.teamId = raw.team_id;
};

View file

@ -5,6 +5,7 @@ import xRegExp from 'xregexp';
import {General} from '@constants';
import type {Model} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
@ -91,3 +92,15 @@ export const filterAndSortMyChannels = ([myChannels, channels, notifyProps]: Fil
// Matches letters from any alphabet and numbers
const sqliteLikeStringRegex = xRegExp('[^\\p{L}\\p{Nd}]', 'g');
export const sanitizeLikeString = (value: string) => value.replace(sqliteLikeStringRegex, '_');
export function removeDuplicatesModels(array: Model[]) {
const seen = new Set();
return array.filter((item) => {
const key = `${item.collection.table}-${item.id}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}

View file

@ -60,7 +60,7 @@ class NetworkManager {
sessionConfiguration: {
allowsCellularAccess: true,
waitsForConnectivity: false,
httpMaximumConnectionsPerHost: 10,
httpMaximumConnectionsPerHost: 100,
cancelRequestsOnUnauthorized: true,
},
retryPolicyConfiguration: {

View file

@ -1162,7 +1162,7 @@ PODS:
- Yoga
- react-native-netinfo (11.3.2):
- React-Core
- react-native-network-client (1.7.2):
- react-native-network-client (1.7.3):
- Alamofire (~> 5.9.1)
- DoubleConversion
- glog
@ -2043,7 +2043,7 @@ SPEC CHECKSUMS:
react-native-emm: ecab44d78fb1cc7d7b7901914f48fb6309c46ff2
react-native-image-picker: c3afe5472ef870d98a4b28415fc0b928161ee5f7
react-native-netinfo: 076df4f9b07f6670acf4ce9a75aac8d34c2e2ccc
react-native-network-client: 5173c47230b5f497cdef469cba8e6e1b3df687eb
react-native-network-client: 82ef7c000c5fd3bfd1ad9e78cd920af33040f9f3
react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4
react-native-paste-input: 011a9916ef3acf809a7da122847c61ca0f93a60e
react-native-performance: ff93f8af3b2ee9519fd7879896aa9b8b8272691d

View file

@ -6,7 +6,7 @@ import React
@objc private var hasRegisteredLoad = false
deinit {
DispatchQueue.main.async {
DispatchQueue.main.sync {
guard let w = UIApplication.shared.delegate?.window, let window = w else { return }
window.removeObserver(self, forKeyPath: "frame")
}

10
package-lock.json generated
View file

@ -6,7 +6,7 @@
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.21.0",
"version": "2.22.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {
@ -22,7 +22,7 @@
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
"@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker",
"@mattermost/react-native-emm": "1.5.0",
"@mattermost/react-native-network-client": "1.7.2",
"@mattermost/react-native-network-client": "1.7.3",
"@mattermost/react-native-paste-input": "0.8.0",
"@mattermost/react-native-turbo-log": "0.4.0",
"@mattermost/rnshare": "file:./libraries/@mattermost/rnshare",
@ -5908,9 +5908,9 @@
}
},
"node_modules/@mattermost/react-native-network-client": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.7.2.tgz",
"integrity": "sha512-UaFhUaQkmb6Hq2LOyutiwL0HeMVHmhBBpgojLi83zXXXB5OgNWDgqbDBo6Y986c5TwhCy1/LxENdHBc+mI4RZA==",
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.7.3.tgz",
"integrity": "sha512-gJSQ4Prf5jcbPlxVylhEtBGafSBYZSat+T21LIDHksGOfeY+jvPL3p8dS+cq+0D1AOHQ3tHNBkZ7RaY6IdUadw==",
"license": "MIT",
"dependencies": {
"validator": "13.12.0",

View file

@ -23,7 +23,7 @@
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
"@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker",
"@mattermost/react-native-emm": "1.5.0",
"@mattermost/react-native-network-client": "1.7.2",
"@mattermost/react-native-network-client": "1.7.3",
"@mattermost/react-native-paste-input": "0.8.0",
"@mattermost/react-native-turbo-log": "0.4.0",
"@mattermost/rnshare": "file:./libraries/@mattermost/rnshare",