diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index 493d58e0d..4aa096adb 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -12,6 +12,7 @@ import PushNotifications from '@init/push_notifications'; import { prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel, getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes, + prepareAllMyChannels, } from '@queries/servers/channel'; import {queryDisplayNamePreferences} from '@queries/servers/preference'; import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system'; @@ -23,6 +24,7 @@ import {isTablet} from '@utils/helpers'; import {logError, logInfo} from '@utils/log'; import {displayGroupMessageName, displayUsername, getUserIdFromChannelName} from '@utils/user'; +import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Model} from '@nozbe/watermelondb'; import type ChannelModel from '@typings/database/models/servers/channel'; import type UserModel from '@typings/database/models/servers/user'; @@ -237,6 +239,43 @@ export async function resetMessageCount(serverUrl: string, channelId: string) { } } +const resolveAndFlattenModels = async ( + operator: ServerDataOperator, + modelPromises: Array>, + description: string, + prepareRecordsOnly: boolean, +) => { + try { + const models = await Promise.all(modelPromises); + const flattenedModels = models.flat(); + + if (prepareRecordsOnly || !flattenedModels.length) { + return {models: flattenedModels, error: undefined}; + } + + await operator.batchRecords(flattenedModels, description); + return {models: flattenedModels, error: undefined}; + } catch (error) { + logError('Failed resolveAndFlattenModels', {error, description}); + return {models: [], error}; + } +}; + +export async function storeAllMyChannels(serverUrl: string, channels: Channel[], memberships: ChannelMembership[], isCRTEnabled: boolean, prepareRecordsOnly = false) { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const cs = new Set(channels.map((c) => c.id)); + const modelPromises: Array> = [ + ...await prepareAllMyChannels(operator, channels, memberships.filter((m) => cs.has(m.channel_id)), isCRTEnabled), + ]; + + return resolveAndFlattenModels(operator, modelPromises, 'storeAllMyChannels', prepareRecordsOnly); + } catch (error) { + logError('Failed storeAllMyChannels', {error, serverUrl, channelsCount: channels.length}); + return {error, models: undefined}; + } +} + export async function storeMyChannelsForTeam(serverUrl: string, teamId: string, channels: Channel[], memberships: ChannelMembership[], prepareRecordsOnly = false, isCRTEnabled?: boolean) { try { const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -244,25 +283,10 @@ export async function storeMyChannelsForTeam(serverUrl: string, teamId: string, ...await prepareMyChannelsForTeam(operator, teamId, channels, memberships, isCRTEnabled), ]; - const models = await Promise.all(modelPromises); - if (!models.length) { - return {models: []}; - } - - const flattenedModels = models.flat(); - - if (prepareRecordsOnly) { - return {models: flattenedModels}; - } - - if (flattenedModels.length) { - await operator.batchRecords(flattenedModels, 'storeMyChannelsForTeam'); - } - - return {models: flattenedModels}; + return resolveAndFlattenModels(operator, modelPromises, 'storeMyChannelsForTeam', prepareRecordsOnly); } catch (error) { logError('Failed storeMyChannelsForTeam', error); - return {error}; + return {error, models: undefined}; } } diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 76105f360..fc98c16da 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -5,7 +5,7 @@ import {DeviceEventEmitter} from 'react-native'; import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category'; -import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; +import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; import {loadCallForChannel} from '@calls/actions/calls'; import {DeepLink, Events, General, Preferences, Screens} from '@constants'; @@ -376,7 +376,7 @@ export async function fetchChannelCreator(serverUrl: string, channelId: string, } } -export async function fetchChannelStats(serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: string) { +export async function fetchChannelStats(serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: RequestGroupLabel) { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -404,10 +404,74 @@ export async function fetchChannelStats(serverUrl: string, channelId: string, fe } } +export async function fetchAllMyChannelsForAllTeams(serverUrl: string, since: number, isCRTEnabled: boolean, fetchOnly = false, groupLabel?: RequestGroupLabel) { + try { + if (!fetchOnly) { + setTeamLoading(serverUrl, true); + } + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const promises: [Promise, Promise] = [ + client.getAllChannelsFromAllTeams(since, since > 0, groupLabel), + client.getAllMyChannelMembersFromAllTeams(0, General.CHANNEL_MEMBERS_CHUNK_SIZE, groupLabel), + ]; + + const [channels, memberships] = await Promise.all(promises); + const remaining = Math.floor(channels.length / General.CHANNEL_MEMBERS_CHUNK_SIZE); + const remainingMembershipsPromises = []; + const categoriesPromises = []; + + const teamIdsSet = channels.reduce>((set, c) => { + if (c.team_id) { + set.add(c.team_id); + } + return set; + }, new Set()); + for (const teamId of teamIdsSet) { + categoriesPromises.push(client.getCategories('me', teamId, groupLabel)); + } + + if (remaining > 0) { + for (let i = 1; i <= remaining; i++) { + remainingMembershipsPromises.push(client.getAllMyChannelMembersFromAllTeams(i, General.CHANNEL_MEMBERS_CHUNK_SIZE, groupLabel)); + } + } + + const results = await Promise.all([...remainingMembershipsPromises, ...categoriesPromises]); + const categories: CategoryWithChannels[] = []; + for (const result of results) { + if (Array.isArray(result)) { + memberships.push(...result); + } else if ('categories' in result) { + categories.push(...result.categories); + } + } + + if (!fetchOnly) { + const {models: chModels} = await storeAllMyChannels(serverUrl, channels, memberships, isCRTEnabled, true); + const {models: catModels} = await storeCategories(serverUrl, categories, true, true); // Re-sync + const models = (chModels || []).concat(catModels || []); + if (models.length) { + await operator.batchRecords(models, 'fetchAllMyChannelsForAllTeams'); + } + setTeamLoading(serverUrl, false); + } + + return {channels, memberships, categories}; + } catch (error) { + logDebug('error on fetchMyChannelsForTeam', getFullErrorMessage(error)); + if (!fetchOnly) { + setTeamLoading(serverUrl, false); + } + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + export async function fetchMyChannelsForTeam( serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false, - isCRTEnabled?: boolean, groupLabel?: string, + isCRTEnabled?: boolean, groupLabel?: RequestGroupLabel, ): Promise { try { if (!fetchOnly) { @@ -457,7 +521,7 @@ export async function fetchMyChannelsForTeam( } } -export async function fetchMyChannel(serverUrl: string, teamId: string, channelId: string, fetchOnly = false, groupLabel?: string): Promise { +export async function fetchMyChannel(serverUrl: string, teamId: string, channelId: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise { try { const client = NetworkManager.getClient(serverUrl); @@ -484,7 +548,7 @@ export async function fetchMyChannel(serverUrl: string, teamId: string, channelI export async function fetchMissingDirectChannelsInfo( serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, currentUserId?: string, - fetchOnly = false, groupLabel?: string, + fetchOnly = false, groupLabel?: RequestGroupLabel, ) { try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -662,7 +726,7 @@ export async function joinChannelIfNeeded(serverUrl: string, channelId: string) } } -export async function markChannelAsRead(serverUrl: string, channelId: string, updateLocal = false, groupLabel?: string) { +export async function markChannelAsRead(serverUrl: string, channelId: string, updateLocal = false, groupLabel?: RequestGroupLabel) { try { const client = NetworkManager.getClient(serverUrl); await client.viewMyChannel(channelId, undefined, groupLabel); @@ -1047,7 +1111,7 @@ export async function getChannelTimezones(serverUrl: string, channelId: string) } } -export async function switchToChannelById(serverUrl: string, channelId: string, teamId?: string, skipLastUnread = false, groupLabel?: string) { +export async function switchToChannelById(serverUrl: string, channelId: string, teamId?: string, skipLastUnread = false, groupLabel?: RequestGroupLabel) { if (channelId === Screens.GLOBAL_THREADS) { return switchToGlobalThreads(serverUrl, teamId); } @@ -1059,7 +1123,7 @@ export async function switchToChannelById(serverUrl: string, channelId: string, DeviceEventEmitter.emit(Events.CHANNEL_SWITCH, true); - fetchPostsForChannel(serverUrl, channelId, false, groupLabel); + fetchPostsForChannel(serverUrl, channelId, false, false, groupLabel); fetchChannelBookmarks(serverUrl, channelId, false, groupLabel); await switchToChannel(serverUrl, channelId, teamId, skipLastUnread); openChannelIfNeeded(serverUrl, channelId, groupLabel); @@ -1258,8 +1322,10 @@ export const handleKickFromChannel = async (serverUrl: string, channelId: string const currentServer = await getActiveServer(); if (currentServer?.url === serverUrl) { const channel = await getChannelById(database, channelId); - DeviceEventEmitter.emit(event, channel?.displayName); - await dismissAllModalsAndPopToRoot(); + if (channel) { + DeviceEventEmitter.emit(event, channel.displayName); + await dismissAllModalsAndPopToRoot(); + } } const tabletDevice = isTablet(); diff --git a/app/actions/remote/channel_bookmark.ts b/app/actions/remote/channel_bookmark.ts index b9640e603..b644aa464 100644 --- a/app/actions/remote/channel_bookmark.ts +++ b/app/actions/remote/channel_bookmark.ts @@ -11,7 +11,7 @@ import {logError} from '@utils/log'; import {forceLogoutIfNecessary} from './session'; -export async function fetchChannelBookmarks(serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: string) { +export async function fetchChannelBookmarks(serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: RequestGroupLabel) { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); diff --git a/app/actions/remote/entry/app.ts b/app/actions/remote/entry/app.ts index c79a2236e..877724489 100644 --- a/app/actions/remote/entry/app.ts +++ b/app/actions/remote/entry/app.ts @@ -4,6 +4,7 @@ import {setLastServerVersionCheck} from '@actions/local/systems'; import {fetchConfigAndLicense} from '@actions/remote/systems'; import DatabaseManager from '@database/manager'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import WebsocketManager from '@managers/websocket_manager'; import {prepareCommonSystemValues} from '@queries/servers/system'; import {deleteV1Data} from '@utils/file'; @@ -16,6 +17,8 @@ export async function appEntry(serverUrl: string, since = 0) { return {error: `${serverUrl} database not found`}; } + PerformanceMetricsManager.startTimeToInteraction(); + if (!since) { if (Object.keys(DatabaseManager.serverDatabases).length === 1) { await setLastServerVersionCheck(serverUrl, true); @@ -28,7 +31,7 @@ export async function appEntry(serverUrl: string, since = 0) { await operator.batchRecords(removeLastUnreadChannelId, 'appEntry - removeLastUnreadChannelId'); } - WebsocketManager.openAll('entry'); + WebsocketManager.openAll('Cold Start'); verifyPushProxy(serverUrl); diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index a73bab82c..c98ce3542 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -4,13 +4,13 @@ import {nativeApplicationVersion} from 'expo-application'; import {RESULTS, checkNotifications} from 'react-native-permissions'; -import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel'; +import {fetchAllMyChannelsForAllTeams, fetchMissingDirectChannelsInfo, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel'; import {fetchGroupsForMember} from '@actions/remote/groups'; 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, fetchTeamsChannelsThreadsAndUnreadPosts, handleKickFromTeam, type MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team'; +import {fetchMyTeams, fetchTeamsThreads, 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'; @@ -22,16 +22,16 @@ import {selectDefaultTeam} from '@helpers/api/team'; import {DEFAULT_LOCALE} from '@i18n'; import NetworkManager from '@managers/network_manager'; import {getDeviceToken} from '@queries/app/global'; -import {getChannelById, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel'; -import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry'; +import {getChannelById} from '@queries/servers/channel'; +import {prepareEntryModels, prepareEntryModelsForDeletion, truncateCrtRelatedTables} from '@queries/servers/entry'; import {getHasCRTChanged} from '@queries/servers/preference'; -import {getConfig, getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system'; -import {deleteMyTeams, getAvailableTeamIds, getTeamChannelHistory, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team'; +import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system'; +import {getTeamChannelHistory} from '@queries/servers/team'; import NavigationStore from '@store/navigation_store'; import {isDMorGM, sortChannelsByDisplayName} from '@utils/channel'; -import {getFullErrorMessage, isErrorWithStatusCode} from '@utils/errors'; +import {getFullErrorMessage} from '@utils/errors'; import {isMinimumServerVersion, isTablet} from '@utils/helpers'; -import {logDebug} from '@utils/log'; +import {logDebug, logError} from '@utils/log'; import {processIsCRTEnabled} from '@utils/thread'; import type {Database, Model} from '@nozbe/watermelondb'; @@ -66,14 +66,16 @@ export type EntryResponse = { error: unknown; } -export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0, groupLabel?: string): Promise => { +export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0, groupLabel?: RequestGroupLabel): Promise => { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - const result = entryRest(serverUrl, teamId, channelId, since, groupLabel); + const result = await entryRest(serverUrl, teamId, channelId, since, groupLabel); // Fetch data retention policies - const isDataRetentionEnabled = await getIsDataRetentionEnabled(database); - if (isDataRetentionEnabled) { - fetchDataRetentionPolicy(serverUrl, false, groupLabel); + if (!result.error) { + const isDataRetentionEnabled = await getIsDataRetentionEnabled(database); + if (isDataRetentionEnabled) { + fetchDataRetentionPolicy(serverUrl, false, groupLabel); + } } return result; @@ -83,7 +85,7 @@ export async function deferredAppEntryActions( 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, - groupLabel?: string, + groupLabel?: BaseRequestGroupLabel, ) { const result = restDeferredAppEntryActions( serverUrl, since, currentUserId, currentUserLocale, @@ -97,242 +99,109 @@ export async function deferredAppEntryActions( return result; } -const getRemoveTeamIds = async (database: Database, teamData: MyTeamsRequest) => { - const myTeams = await queryMyTeams(database).fetch(); - const joinedTeams = new Set(teamData.memberships?.filter((m) => m.delete_at === 0).map((m) => m.team_id)); - return myTeams.filter((m) => !joinedTeams.has(m.id)).map((m) => m.id); -}; +const entryRest = async (serverUrl: string, teamId?: string, channelId?: string, since = 0, groupLabel?: RequestGroupLabel) => { + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + let lastDisconnectedAt = since || await getLastFullSync(database); -const teamsToRemove = async (serverUrl: string, removeTeamIds?: string[]) => { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return []; - } - const {database} = operator; + const [confResp, prefData] = await Promise.all([ + fetchConfigAndLicense(serverUrl, true, groupLabel), + fetchMyPreferences(serverUrl, true, groupLabel), + ]); - if (removeTeamIds?.length) { - // Immediately delete myTeams so that the UI renders only teams the user is a member of. - const removeMyTeams = await queryMyTeamsByIds(database, removeTeamIds).fetch(); - if (removeMyTeams?.length) { - await deleteMyTeams(operator, removeMyTeams); - const ids = removeMyTeams.map((m) => m.id); - const removeTeams = await queryTeamsById(database, ids).fetch(); - return removeTeams; + const isCRTEnabled = Boolean(prefData.preferences && processIsCRTEnabled(prefData.preferences, confResp.config?.CollapsedThreads, confResp.config?.FeatureFlagCollapsedThreads, confResp.config?.Version)); + if (prefData.preferences) { + const crtToggled = await getHasCRTChanged(database, prefData.preferences); + if (crtToggled) { + const currentServerUrl = await DatabaseManager.getActiveServerUrl(); + const isSameServer = currentServerUrl === serverUrl; + if (isSameServer) { + lastDisconnectedAt = 0; + } + const {error} = await truncateCrtRelatedTables(serverUrl); + if (error) { + throw new Error(`Resetting CRT on ${serverUrl} failed`); + } + } } - } - return []; -}; + // let's start fetching in parallel all we can + const promises: [Promise, Promise, Promise] = [ + fetchMyTeams(serverUrl, true, groupLabel), + fetchMe(serverUrl, true, groupLabel), + fetchAllMyChannelsForAllTeams(serverUrl, lastDisconnectedAt, isCRTEnabled, true, groupLabel), + ]; -const entryRest = async (serverUrl: string, teamId?: string, channelId?: string, since = 0, groupLabel?: string): Promise => { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return {error: `${serverUrl} database not found`}; - } - const {database} = operator; + const [teamData, meData, chData] = await Promise.all(promises); + const error = confResp.error || prefData.error || teamData.error || meData.error || chData.error; + if (error) { + return {error}; + } - const lastDisconnectedAt = since || await getLastFullSync(database); + fetchRoles(serverUrl, teamData.memberships, chData.memberships, meData.user, false, false, groupLabel); - const fetchedData = await fetchAppEntryData(serverUrl, lastDisconnectedAt, teamId, channelId, groupLabel); - if ('error' in fetchedData) { - return {error: fetchedData.error}; - } + let initialTeamId = teamId || ''; + let initialChannelId = channelId || ''; + let gmConverted = false; - const {initialTeamId, initialChannelId: fetchedChannelId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds, isCRTEnabled, gmConverted} = fetchedData; - const chError = chData?.error; - if (isErrorWithStatusCode(chError) && chError.status_code === 403) { - // if the user does not have appropriate permissions, which means the user those not belong to the team, - // we set it as there is no errors, so that the teams and others can be properly handled - chData!.error = undefined; - } - const error = teamData.error || chData?.error || prefData.error || meData.error; - if (error) { + if (channelId) { + const existingChannel = await getChannelById(database, channelId); + if (existingChannel && existingChannel.type === General.GM_CHANNEL) { + // Okay, so now we know the channel existsin in mobile app's database as a GM. + // We now need to also check if channel on server is actually a private channel, + // and if so, which team does it belong to now. That team will become the + // active team on mobile app after this point. + + const serverChannel = chData.channels?.find((c) => c.id === channelId); + + // Although yon can convert GM only to a pirvate channel, a private channel can furthur be converted to a public channel. + // So between the mobile app being on the GM and reconnecting, + // it may have become either a public or a private channel. So we need to check for both. + if (serverChannel?.type === General.PRIVATE_CHANNEL || serverChannel?.type === General.OPEN_CHANNEL) { + initialTeamId = serverChannel.team_id; + initialChannelId = channelId; + gmConverted = true; + } + } + } + + if (!teamData.error && teamData.teams?.length === 0) { + initialTeamId = ''; + } + + const inTeam = teamData.teams?.find((t) => t.id === initialTeamId); + if (initialTeamId && !inTeam && !teamData.error) { + initialTeamId = ''; + } + + if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) { + // If no initial team was set in the database but got teams in the response + const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.CATEGORIES.TEAMS_ORDER, '', ''); + const teamMembers = new Set(teamData.memberships.filter((m) => m.delete_at === 0).map((m) => m.team_id)); + const myTeams = teamData.teams.filter((t) => teamMembers.has(t.id)); + const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, confResp.config?.ExperimentalPrimaryTeam); + if (defaultTeam?.id) { + initialTeamId = defaultTeam.id; + } + } + + initialChannelId = await entryInitialChannelId(database, initialChannelId, teamId, initialTeamId, meData?.user?.locale || '', chData?.channels, chData?.memberships); + + const dt = Date.now(); + const modelsToDeletePromises = await prepareEntryModelsForDeletion({operator, teamData, chData}); + const modelPromises = await prepareEntryModels({operator, teamData, chData, prefData, meData, isCRTEnabled}); + const modelsToDelete = await Promise.all(modelsToDeletePromises); + const models = await Promise.all(modelPromises); + if (modelsToDelete.length) { + models.push(...modelsToDelete); + } + logDebug('Process models on entry', groupLabel, models.flat().length, `${Date.now() - dt}ms`); + + return {models: models.flat(), initialChannelId, initialTeamId, prefData, teamData, chData, meData, gmConverted}; + } catch (error) { + logError('entryRest', groupLabel, error); return {error}; } - - const rolesData = await fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData.user, true, false, groupLabel); - - const initialChannelId = await entryInitialChannelId(database, fetchedChannelId, teamId, initialTeamId, meData?.user?.locale || '', chData?.channels, chData?.memberships); - - const removeTeams = await teamsToRemove(serverUrl, removeTeamIds); - - let removeChannels; - if (removeChannelIds?.length) { - removeChannels = await queryChannelsById(database, removeChannelIds).fetch(); - } - - const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData, isCRTEnabled}); - if (rolesData.roles?.length) { - modelPromises.push(operator.handleRole({roles: rolesData.roles, prepareRecordsOnly: true})); - } - - const models = await Promise.all(modelPromises); - - return {models: models.flat(), initialChannelId, initialTeamId, prefData, teamData, chData, meData, gmConverted}; -}; - -const fetchAppEntryData = async (serverUrl: string, sinceArg: number, onLoadTeamId = '', channelId?: string, groupLabel?: string): Promise => { - const database = DatabaseManager.serverDatabases[serverUrl]?.database; - if (!database) { - return {error: `${serverUrl} database not found`}; - } - let since = sinceArg; - const includeDeletedChannels = true; - const fetchOnly = true; - - const confReq = await fetchConfigAndLicense(serverUrl, false, groupLabel); - const prefData = await fetchMyPreferences(serverUrl, fetchOnly, groupLabel); - const isCRTEnabled = Boolean(prefData.preferences && processIsCRTEnabled(prefData.preferences, confReq.config?.CollapsedThreads, confReq.config?.FeatureFlagCollapsedThreads, confReq.config?.Version)); - if (prefData.preferences) { - const crtToggled = await getHasCRTChanged(database, prefData.preferences); - if (crtToggled) { - const currentServerUrl = await DatabaseManager.getActiveServerUrl(); - const isSameServer = currentServerUrl === serverUrl; - if (isSameServer) { - since = 0; - } - const {error} = await truncateCrtRelatedTables(serverUrl); - if (error) { - return {error: `Resetting CRT on ${serverUrl} failed`}; - } - } - } - - // Fetch in parallel teams / team membership / user preferences / user - const promises: [Promise, Promise] = [ - fetchMyTeams(serverUrl, fetchOnly, groupLabel), - fetchMe(serverUrl, fetchOnly, groupLabel), - ]; - - const resolution = await Promise.all(promises); - const [teamData, meData] = resolution; - let chData; - - let initialTeamId = onLoadTeamId; - let initialChannelId = channelId; - let gmConverted = false; - - if (channelId) { - const existingChannel = await getChannelById(database, channelId); - if (existingChannel && existingChannel.type === General.GM_CHANNEL) { - // Okay, so now we know the channel existsin in mobile app's database as a GM. - // We now need to also check if channel on server is actually a private channel, - // and if so, which team does it belong to now. That team will become the - // active team on mobile app after this point. - - const client = NetworkManager.getClient(serverUrl); - const serverChannel = await client.getChannel(channelId, groupLabel); - - // Although yon can convert GM only to a pirvate channel, a private channel can furthur be converted to a public channel. - // So between the mobile app being on the GM and reconnecting, - // it may have become either a public or a private channel. So we need to check for both. - if (serverChannel.type === General.PRIVATE_CHANNEL || serverChannel.type === General.OPEN_CHANNEL) { - initialTeamId = serverChannel.team_id; - initialChannelId = channelId; - gmConverted = true; - } - } - } - - if (initialTeamId) { - chData = await fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, since, fetchOnly, false, isCRTEnabled, groupLabel); - } - - if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) { - // If no initial team was set in the database but got teams in the response - const config = await getConfig(database); - const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.CATEGORIES.TEAMS_ORDER, '', ''); - const teamMembers = new Set(teamData.memberships.filter((m) => m.delete_at === 0).map((m) => m.team_id)); - const myTeams = teamData.teams!.filter((t) => teamMembers.has(t.id)); - const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, config?.ExperimentalPrimaryTeam); - if (defaultTeam?.id) { - chData = await fetchMyChannelsForTeam(serverUrl, defaultTeam.id, includeDeletedChannels, since, fetchOnly, false, isCRTEnabled, groupLabel); - } - } - - const removeTeamIds = await getRemoveTeamIds(database, teamData); - - let data: AppEntryData = { - initialTeamId, - teamData, - chData, - prefData, - meData, - removeTeamIds, - isCRTEnabled, - initialChannelId, - gmConverted, - }; - - if (teamData.teams?.length === 0 && !teamData.error) { - return { - ...data, - initialTeamId: '', - removeTeamIds, - }; - } - - const inTeam = teamData.teams?.find((t) => t.id === initialTeamId); - const chError = chData?.error; - if ((!inTeam && !teamData.error) || (isErrorWithStatusCode(chError) && chError.status_code === 403)) { - // User is no longer a member of the current team - if (!removeTeamIds.includes(initialTeamId)) { - removeTeamIds.push(initialTeamId); - } - - const availableTeamIds = await getAvailableTeamIds(database, initialTeamId, teamData.teams, prefData.preferences, meData.user?.locale); - const alternateTeamData = await fetchAlternateTeamData(serverUrl, availableTeamIds, removeTeamIds, includeDeletedChannels, since, fetchOnly, isCRTEnabled, groupLabel); - - data = { - ...data, - ...alternateTeamData, - }; - } - - if (data.chData?.channels) { - const removeChannelIds: string[] = []; - const fetchedChannelIds = new Set(data.chData.channels.map((channel) => channel.id)); - - const channels = await queryAllChannelsForTeam(database, initialTeamId).fetch(); - for (const channel of channels) { - if (!fetchedChannelIds.has(channel.id)) { - removeChannelIds.push(channel.id); - } - } - - data = { - ...data, - removeChannelIds, - }; - } - - return data; -}; - -const fetchAlternateTeamData = async ( - serverUrl: string, availableTeamIds: string[], removeTeamIds: string[], - includeDeleted = true, since = 0, fetchOnly = false, - isCRTEnabled?: boolean, groupLabel?: string, -) => { - let initialTeamId = ''; - let chData; - - for await (const teamId of availableTeamIds) { - chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly, false, isCRTEnabled, groupLabel); - const chError = chData.error; - if (isErrorWithStatusCode(chError) && chError.status_code === 403) { - removeTeamIds.push(teamId); - } else { - initialTeamId = teamId; - break; - } - } - - if (chData) { - return {initialTeamId, chData, removeTeamIds}; - } - - return {initialTeamId, removeTeamIds}; }; async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) { @@ -376,67 +245,69 @@ 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, groupLabel?: string, + initialTeamId?: string, initialChannelId?: string, groupLabel?: BaseRequestGroupLabel, ) { const isCRTEnabled = (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) || false; const directChannels = chData?.channels?.filter(isDMorGM); const channelsToFetchProfiles = new Set(directChannels); + const requestLabel: RequestGroupLabel|undefined = groupLabel ? `${groupLabel} Deferred` : undefined; // sidebar DM & GM profiles if (channelsToFetchProfiles.size) { const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license); - fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId, false, groupLabel); + fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId, false, requestLabel); } - updateAllUsersSince(serverUrl, since, false, groupLabel); + updateAllUsersSince(serverUrl, since, false, requestLabel); updateCanJoinTeams(serverUrl); setTimeout(async () => { - if (chData?.channels?.length && chData.memberships?.length && initialTeamId) { - if (isCRTEnabled && initialTeamId) { - await syncTeamThreads(serverUrl, initialTeamId, {groupLabel}); - } - fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId, false, groupLabel); - } - - // defer fetch channels and unread posts for other teams + let mySortedTeams: Team[] = []; 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])); - if (initialTeamId) { - sortedTeamIds.delete(initialTeamId); - membershipSet.delete(initialTeamId); - teamMap.delete(initialTeamId); - } - - let myTeams: Team[]; if (sortedTeamIds.size) { - const mySortedTeams = [...sortedTeamIds]. + const sortedTeams = [...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]; + mySortedTeams = [...sortedTeams, ...extraTeams]; } else { - myTeams = teamData.teams. + mySortedTeams = teamData.teams. sort((a, b) => a.display_name.toLocaleLowerCase().localeCompare(b.display_name.toLocaleLowerCase())); } - if (myTeams.length) { - fetchTeamsChannelsThreadsAndUnreadPosts(serverUrl, since, myTeams, isCRTEnabled, false, groupLabel + '-additional'); + const myOtherSortedTeams = mySortedTeams.filter((t) => t.id !== initialTeamId); + if (initialTeamId) { + const initialTeam = teamMap.get(initialTeamId); + if (initialTeam) { + mySortedTeams = [initialTeam, ...myOtherSortedTeams]; + } + } + + if (chData?.channels?.length && chData.memberships?.length && initialTeamId) { + if (isCRTEnabled && initialTeamId) { + await syncTeamThreads(serverUrl, initialTeamId, {groupLabel: requestLabel}); + } + fetchPostsForUnreadChannels(serverUrl, mySortedTeams, chData.channels, chData.memberships, initialChannelId, isCRTEnabled, false, requestLabel); + } + + if (myOtherSortedTeams.length) { + fetchTeamsThreads(serverUrl, since, myOtherSortedTeams, isCRTEnabled, false, requestLabel); } } }); // Fetch groups for current user - fetchGroupsForMember(serverUrl, currentUserId, false, groupLabel); + fetchGroupsForMember(serverUrl, currentUserId, false, requestLabel); } -export const setExtraSessionProps = async (serverUrl: string, groupLabel?: string) => { +export const setExtraSessionProps = async (serverUrl: string, groupLabel?: RequestGroupLabel) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const serverVersion = await getConfigValue(database, 'Version'); @@ -459,7 +330,7 @@ export const setExtraSessionProps = async (serverUrl: string, groupLabel?: strin } }; -export async function verifyPushProxy(serverUrl: string, groupLabel?: string) { +export async function verifyPushProxy(serverUrl: string, groupLabel?: RequestGroupLabel) { const deviceId = await getDeviceToken(); if (!deviceId) { return; diff --git a/app/actions/remote/entry/login.ts b/app/actions/remote/entry/login.ts index c59acb423..8cce868b2 100644 --- a/app/actions/remote/entry/login.ts +++ b/app/actions/remote/entry/login.ts @@ -22,6 +22,9 @@ export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?: // sure we don't do this by skipping the load metric here. PerformanceMetricsManager.skipLoadMetric(); + // But still we want to log the TTI + PerformanceMetricsManager.startTimeToInteraction(); + try { const clData = await fetchConfigAndLicense(serverUrl, false); if (clData.error) { @@ -31,7 +34,7 @@ export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?: const credentials = await getServerCredentials(serverUrl); if (credentials?.token) { WebsocketManager.createClient(serverUrl, credentials.token); - await WebsocketManager.initializeClient(serverUrl, 'login'); + await WebsocketManager.initializeClient(serverUrl, 'Login'); } return {}; diff --git a/app/actions/remote/entry/notification.ts b/app/actions/remote/entry/notification.ts index c78a64ce1..ad4460a89 100644 --- a/app/actions/remote/entry/notification.ts +++ b/app/actions/remote/entry/notification.ts @@ -24,7 +24,7 @@ import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type MyTeamModel from '@typings/database/models/servers/my_team'; import type PostModel from '@typings/database/models/servers/post'; -export async function pushNotificationEntry(serverUrl: string, notification: NotificationData, groupLabel?: string) { +export async function pushNotificationEntry(serverUrl: string, notification: NotificationData, groupLabel?: BaseRequestGroupLabel) { // We only reach this point if we have a channel Id in the notification payload const channelId = notification.channel_id!; const rootId = notification.root_id!; @@ -33,6 +33,9 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not if (!operator) { return {error: `${serverUrl} database not found`}; } + + PerformanceMetricsManager.startTimeToInteraction(); + const {database} = operator; const currentTeamId = await getCurrentTeamId(database); diff --git a/app/actions/remote/groups.ts b/app/actions/remote/groups.ts index fc2d38256..e9d666f68 100644 --- a/app/actions/remote/groups.ts +++ b/app/actions/remote/groups.ts @@ -66,7 +66,7 @@ export const fetchGroupsByNames = async (serverUrl: string, names: string[], fet } }; -export const fetchGroupsForChannel = async (serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: string) => { +export const fetchGroupsForChannel = async (serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: RequestGroupLabel) => { try { const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const license = await getLicense(database); @@ -129,7 +129,7 @@ export const fetchGroupsForTeam = async (serverUrl: string, teamId: string, fetc } }; -export const fetchGroupsForMember = async (serverUrl: string, userId: string, fetchOnly = false, groupLabel?: string) => { +export const fetchGroupsForMember = async (serverUrl: string, userId: string, fetchOnly = false, groupLabel?: RequestGroupLabel) => { try { const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const license = await getLicense(database); @@ -191,7 +191,7 @@ export const fetchGroupsForTeamIfConstrained = async (serverUrl: string, teamId: } }; -export const fetchGroupsForChannelIfConstrained = async (serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: string) => { +export const fetchGroupsForChannelIfConstrained = async (serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: RequestGroupLabel) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const channel = await getChannelById(database, channelId); diff --git a/app/actions/remote/post.test.ts b/app/actions/remote/post.test.ts index 998d4765d..9c67ee5a3 100644 --- a/app/actions/remote/post.test.ts +++ b/app/actions/remote/post.test.ts @@ -581,7 +581,7 @@ describe('get posts', () => { await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); await operator.handleMyChannel({channels: [channel1], myChannels: [channelMember1], prepareRecordsOnly: false}); - const result = await fetchPostsForUnreadChannels(serverUrl, [channel1, {...channel1, id: 'channelid2', total_msg_count: 10}], [{...channelMember1, msg_count: 5}, {...channelMember1, channel_id: 'channelid2', msg_count: 10}], 'testid'); + const result = await fetchPostsForUnreadChannels(serverUrl, [{id: teamId}] as Team[], [channel1, {...channel1, id: 'channelid2', total_msg_count: 10}], [{...channelMember1, msg_count: 5}, {...channelMember1, channel_id: 'channelid2', msg_count: 10}], 'testid'); expect(result).toBeDefined(); expect(result?.length).toBe(1); // Only returns the response for the channel with unread messages expect(result?.[0].posts?.[0].channel_id).toBe(channel1.id); diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index acf8e3c4b..4ed1be887 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -284,7 +284,7 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => { return {}; }; -export async function fetchPostsForChannel(serverUrl: string, channelId: string, fetchOnly = false, groupLabel?: string): Promise { +export async function fetchPostsForChannel(serverUrl: string, channelId: string, fetchOnly = false, skipAuthors = false, groupLabel?: RequestGroupLabel): Promise { try { if (!fetchOnly) { EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId); @@ -309,8 +309,10 @@ export async function fetchPostsForChannel(serverUrl: string, channelId: string, } let authors: UserProfile[] = []; if (data.posts?.length && data.order?.length) { - const {authors: fetchedAuthors} = await fetchPostAuthors(serverUrl, data.posts, true, groupLabel); - authors = fetchedAuthors || []; + if (!skipAuthors) { + const {authors: fetchedAuthors} = await fetchPostAuthors(serverUrl, data.posts, true, groupLabel); + authors = fetchedAuthors || []; + } if (!fetchOnly) { await storePostsForChannel( @@ -333,39 +335,84 @@ export async function fetchPostsForChannel(serverUrl: string, channelId: string, } export const fetchPostsForUnreadChannels = async ( - serverUrl: string, channels: Channel[], memberships: ChannelMembership[], - excludeChannelId?: string, fetchOnly = false, groupLabel?: string, + serverUrl: string, teams: Team[], channels: Channel[], memberships: ChannelMembership[], + excludeChannelId?: string, isCRTEnabled?: boolean, fetchOnly = false, groupLabel?: RequestGroupLabel, ): Promise => { - const membersMap = new Map(); - for (const member of memberships) { - membersMap.set(member.channel_id, member); + const teamIndexMap = new Map(); + teams.forEach((team, index) => teamIndexMap.set(team.id, index)); + + const channelsMap = new Map(); + for (const channel of channels) { + channelsMap.set(channel.id, channel); } - const unreadChannelIds = channels.reduce((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); + const sortedUnreadchannelIds = memberships.filter((member) => { + const channel = channelsMap.get(member.channel_id); + if (channel && !channel.delete_at && channel.id !== excludeChannelId) { + const unreads = isCRTEnabled ? (channel.total_msg_count_root ?? 0) - (member.msg_count_root ?? 0) : channel.total_msg_count - member.msg_count; + return unreads > 0; // Keep only channels with unreads } - return result; - }, []); + + return false; + }). + sort((a, b) => { + const channelA = channelsMap.get(a.channel_id); + const channelB = channelsMap.get(b.channel_id); + + if (!channelA || !channelB) { + return 0; // If either channel is undefined, treat as equal + } + + // Priority for channels with an empty team_id + if (channelA.team_id === '' && channelB.team_id !== '') { + return -1; + } + if (channelB.team_id === '' && channelA.team_id !== '') { + return 1; + } + + // Get team indices for sorting + const teamIndexA = teamIndexMap.get(channelA.team_id) ?? Number.MAX_SAFE_INTEGER; + const teamIndexB = teamIndexMap.get(channelB.team_id) ?? Number.MAX_SAFE_INTEGER; + + // Sort by team index first + if (teamIndexA !== teamIndexB) { + return teamIndexA - teamIndexB; + } + + // If team index is the same, sort by last_viewed_at in descending order + return b.last_viewed_at - a.last_viewed_at; + }). + map((member) => member.channel_id); const postsForChannel: PostsForChannel[] = []; + const posts: Post[] = []; // process 10 unread channels at a time - const chunks = chunk(unreadChannelIds, 10); + const chunks = chunk(sortedUnreadchannelIds, 10); for await (const channelIds of chunks) { const promises = []; for (const channelId of channelIds) { - promises.push(fetchPostsForChannel(serverUrl, channelId, fetchOnly, groupLabel)); + promises.push(fetchPostsForChannel(serverUrl, channelId, fetchOnly, true, groupLabel)); } const results = await Promise.all(promises); postsForChannel.push(...results); + for (const r of results) { + if (r.posts?.length) { + posts.push(...r.posts); + } + } } + + if (posts.length) { + fetchPostAuthors(serverUrl, posts, false, groupLabel); + } + return postsForChannel; }; -export async function fetchPosts(serverUrl: string, channelId: string, page = 0, perPage = General.POST_CHUNK_SIZE, fetchOnly = false, groupLabel?: string): Promise { +export async function fetchPosts(serverUrl: string, channelId: string, page = 0, perPage = General.POST_CHUNK_SIZE, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise { try { if (!fetchOnly) { EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId); @@ -465,7 +512,7 @@ export async function fetchPostsBefore(serverUrl: string, channelId: string, pos } } -export async function fetchPostsSince(serverUrl: string, channelId: string, since: number, fetchOnly = false, groupLabel?: string): Promise { +export async function fetchPostsSince(serverUrl: string, channelId: string, since: number, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise { try { if (!fetchOnly) { EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId); @@ -512,7 +559,7 @@ export async function fetchPostsSince(serverUrl: string, channelId: string, sinc } } -export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOnly = false, groupLabel?: string): Promise => { +export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOnly = false, groupLabel?: RequestGroupLabel): Promise => { try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client = NetworkManager.getClient(serverUrl); @@ -575,7 +622,7 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn } }; -export async function fetchPostThread(serverUrl: string, postId: string, options?: FetchPaginatedThreadOptions, fetchOnly = false, groupLabel?: string) { +export async function fetchPostThread(serverUrl: string, postId: string, options?: FetchPaginatedThreadOptions, fetchOnly = false, groupLabel?: RequestGroupLabel) { try { setFetchingThreadState(postId, true); const client = NetworkManager.getClient(serverUrl); @@ -746,7 +793,7 @@ export async function fetchMissingChannelsFromPosts(serverUrl: string, posts: Po } } -export async function fetchPostById(serverUrl: string, postId: string, fetchOnly = false, groupLabel?: string) { +export async function fetchPostById(serverUrl: string, postId: string, fetchOnly = false, groupLabel?: RequestGroupLabel) { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); diff --git a/app/actions/remote/preference.ts b/app/actions/remote/preference.ts index 5b16b4ff0..2793f5e69 100644 --- a/app/actions/remote/preference.ts +++ b/app/actions/remote/preference.ts @@ -28,7 +28,7 @@ export type MyPreferencesRequest = { error?: unknown; }; -export const fetchMyPreferences = async (serverUrl: string, fetchOnly = false, groupLabel?: string): Promise => { +export const fetchMyPreferences = async (serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise => { try { const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -84,7 +84,7 @@ export const savePostPreference = async (serverUrl: string, postId: string) => { } }; -export const savePreference = async (serverUrl: string, preferences: PreferenceType[], prepareRecordsOnly = false, groupLabel?: string) => { +export const savePreference = async (serverUrl: string, preferences: PreferenceType[], prepareRecordsOnly = false, groupLabel?: RequestGroupLabel) => { try { if (!preferences.length) { return {preferences: []}; @@ -141,7 +141,7 @@ export const deleteSavedPost = async (serverUrl: string, postId: string) => { } }; -export const openChannelIfNeeded = async (serverUrl: string, channelId: string, groupLabel?: string) => { +export const openChannelIfNeeded = async (serverUrl: string, channelId: string, groupLabel?: RequestGroupLabel) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const channel = await getChannelById(database, channelId); @@ -156,7 +156,7 @@ export const openChannelIfNeeded = async (serverUrl: string, channelId: string, } }; -export const openAllUnreadChannels = async (serverUrl: string, groupLabel?: string) => { +export const openAllUnreadChannels = async (serverUrl: string, groupLabel?: RequestGroupLabel) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const channels = await queryAllUnreadDMsAndGMsIds(database).fetch(); @@ -168,7 +168,7 @@ export const openAllUnreadChannels = async (serverUrl: string, groupLabel?: stri } }; -const openChannels = async (serverUrl: string, channels: ChannelModel[], groupLabel?: string) => { +const openChannels = async (serverUrl: string, channels: ChannelModel[], groupLabel?: RequestGroupLabel) => { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const userId = await getCurrentUserId(database); diff --git a/app/actions/remote/role.ts b/app/actions/remote/role.ts index 875661e4f..af7d14346 100644 --- a/app/actions/remote/role.ts +++ b/app/actions/remote/role.ts @@ -17,7 +17,7 @@ export type RolesRequest = { export const fetchRolesIfNeeded = async ( serverUrl: string, updatedRoles: string[], - fetchOnly = false, force = false, groupLabel?: string, + fetchOnly = false, force = false, groupLabel?: RequestGroupLabel, ): Promise => { if (!updatedRoles.length) { return {roles: []}; @@ -70,7 +70,7 @@ export const fetchRolesIfNeeded = async ( export const fetchRoles = async ( serverUrl: string, teamMembership?: TeamMembership[], channelMembership?: ChannelMembership[], - user?: UserProfile, fetchOnly = false, force = false, groupLabel?: string, + user?: UserProfile, fetchOnly = false, force = false, groupLabel?: RequestGroupLabel, ) => { const rolesToFetch = new Set(user?.roles.split(' ') || []); diff --git a/app/actions/remote/systems.ts b/app/actions/remote/systems.ts index 6f365c821..8da1ac617 100644 --- a/app/actions/remote/systems.ts +++ b/app/actions/remote/systems.ts @@ -22,7 +22,7 @@ export type DataRetentionPoliciesRequest = { error?: unknown; } -export const fetchDataRetentionPolicy = async (serverUrl: string, fetchOnly = false, groupLabel?: string): Promise => { +export const fetchDataRetentionPolicy = async (serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise => { const {data: globalPolicy, error: globalPolicyError} = await fetchGlobalDataRetentionPolicy(serverUrl, groupLabel); const {data: teamPolicies, error: teamPoliciesError} = await fetchAllGranularDataRetentionPolicies(serverUrl, undefined, undefined, undefined, groupLabel); const {data: channelPolicies, error: channelPoliciesError} = await fetchAllGranularDataRetentionPolicies(serverUrl, true); @@ -45,7 +45,7 @@ export const fetchDataRetentionPolicy = async (serverUrl: string, fetchOnly = fa return data; }; -export const fetchGlobalDataRetentionPolicy = async (serverUrl: string, groupLabel?: string): Promise<{data?: GlobalDataRetentionPolicy; error?: unknown}> => { +export const fetchGlobalDataRetentionPolicy = async (serverUrl: string, groupLabel?: RequestGroupLabel): Promise<{data?: GlobalDataRetentionPolicy; error?: unknown}> => { try { const client = NetworkManager.getClient(serverUrl); const data = await client.getGlobalDataRetentionPolicy(groupLabel); @@ -62,7 +62,7 @@ export const fetchAllGranularDataRetentionPolicies = async ( isChannel = false, page = 0, policies: Array = [], - groupLabel?: string, + groupLabel?: RequestGroupLabel, ): Promise<{data?: Array; error?: unknown}> => { try { const client = NetworkManager.getClient(serverUrl); @@ -86,7 +86,7 @@ export const fetchAllGranularDataRetentionPolicies = async ( } }; -export const fetchConfigAndLicense = async (serverUrl: string, fetchOnly = false, groupLabel?: string): Promise => { +export const fetchConfigAndLicense = async (serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise => { try { const client = NetworkManager.getClient(serverUrl); const [config, license]: [ClientConfig, ClientLicense] = await Promise.all([ diff --git a/app/actions/remote/team.test.ts b/app/actions/remote/team.test.ts index 023fceb9f..6427d82b4 100644 --- a/app/actions/remote/team.test.ts +++ b/app/actions/remote/team.test.ts @@ -25,7 +25,7 @@ import { handleKickFromTeam, getTeamMembersByIds, buildTeamIconUrl, - fetchTeamsChannelsThreadsAndUnreadPosts, + fetchTeamsThreads, } from './team'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -271,20 +271,20 @@ describe('teams', () => { expect(result).toBeDefined(); }); - it('fetchTeamsChannelsThreadsAndUnreadPosts - handle not found database', async () => { - const result = await fetchTeamsChannelsThreadsAndUnreadPosts('foo', 0, []) as {error: unknown}; + it('fetchTeamsThreads - handle not found database', async () => { + const result = await fetchTeamsThreads('foo', 0, []) as {error: unknown}; expect(result?.error).toBeDefined(); }); - it('fetchTeamsChannelsThreadsAndUnreadPosts - base case', async () => { - const result = await fetchTeamsChannelsThreadsAndUnreadPosts( + it('fetchTeamsThreads - base case', async () => { + const result = await fetchTeamsThreads( serverUrl, 0, [team], true, false); expect(result).toBeDefined(); }); - it('fetchTeamsChannelsThreadsAndUnreadPosts - fetch only case', async () => { - const result = await fetchTeamsChannelsThreadsAndUnreadPosts( + it('fetchTeamsThreads - fetch only case', async () => { + const result = await fetchTeamsThreads( serverUrl, 0, [team], true, true); expect(result.models).toBeDefined(); diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 59b202196..d645b1d61 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -4,9 +4,6 @@ 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'; @@ -27,7 +24,7 @@ import {logDebug} from '@utils/log'; import {fetchMyChannelsForTeam, switchToChannelById} from './channel'; import {fetchGroupsForTeamIfConstrained} from './groups'; -import {fetchPostsForChannel, fetchPostsForUnreadChannels, type PostsForChannel} from './post'; +import {fetchPostsForChannel} from './post'; import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; import {syncThreadsIfNeeded} from './thread'; @@ -161,7 +158,7 @@ export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, } } -export async function fetchMyTeams(serverUrl: string, fetchOnly = false, groupLabel?: string): Promise { +export async function fetchMyTeams(serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -207,7 +204,7 @@ export async function fetchMyTeams(serverUrl: string, fetchOnly = false, groupLa } } -export async function fetchMyTeam(serverUrl: string, teamId: string, fetchOnly = false, groupLabel?: string): Promise { +export async function fetchMyTeam(serverUrl: string, teamId: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise { try { const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -247,7 +244,7 @@ export const fetchAllTeams = async (serverUrl: string, page = 0, perPage = PER_P } }; -const recCanJoinTeams = async (client: Client, myTeamsIds: Set, page: number, groupLabel?: string): Promise => { +const recCanJoinTeams = async (client: Client, myTeamsIds: Set, page: number, groupLabel?: RequestGroupLabel): Promise => { const fetchedTeams = await client.getTeams(page, PER_PAGE_DEFAULT, false, groupLabel); if (fetchedTeams.find((t) => !myTeamsIds.has(t.id) && t.delete_at === 0)) { return true; @@ -298,7 +295,7 @@ export async function fetchTeamsForComponent( return {teams: alreadyLoaded, hasMore: false, page}; } -export const updateCanJoinTeams = async (serverUrl: string, groupLabel?: string) => { +export const updateCanJoinTeams = async (serverUrl: string, groupLabel?: RequestGroupLabel) => { try { const client = NetworkManager.getClient(serverUrl); const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -318,9 +315,8 @@ export const updateCanJoinTeams = async (serverUrl: string, groupLabel?: string) } }; -export const fetchTeamsChannelsThreadsAndUnreadPosts = async ( - serverUrl: string, since: number, teams: Team[], - isCRTEnabled?: boolean, fetchOnly = false, groupLabel?: string, +export const fetchTeamsThreads = async ( + serverUrl: string, since: number, teams: Team[], isCRTEnabled?: boolean, fetchOnly = false, groupLabel?: RequestGroupLabel, ) => { try { const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -329,62 +325,14 @@ export const fetchTeamsChannelsThreadsAndUnreadPosts = async ( // 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, groupLabel)); - } - - const results = await Promise.all(promises); const models: Model[] = []; - const channels: Channel[] = []; - const members: ChannelMembership[] = []; - - 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, groupLabel); - const threadsPromise = syncThreadsIfNeeded(serverUrl, isCRTEnabled ?? false, myTeams, true, groupLabel); - const postPromises: [Promise, 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); - } - } - } - + const threads = await syncThreadsIfNeeded(serverUrl, isCRTEnabled ?? false, myTeams, true, groupLabel); if (threads.models) { models.push(...threads.models); } if (!fetchOnly && models.length) { - await operator.batchRecords(removeDuplicatesModels(models), 'fetchTeamsChannelsThreadsAndUnreadPosts'); + await operator.batchRecords(removeDuplicatesModels(models), 'fetchTeamsThreads'); } if (models.length) { @@ -394,7 +342,7 @@ export const fetchTeamsChannelsThreadsAndUnreadPosts = async ( return {error: undefined, models: result}; } catch (error) { - logDebug('error on fetchTeamsChannelsThreadsAndUnreadPosts', getFullErrorMessage(error)); + logDebug('error on fetchTeamsThreads', getFullErrorMessage(error)); return {error}; } }; diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index 5418c9a08..bb3f71a32 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -39,7 +39,7 @@ enum Direction { Down, } -export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string, isFromNotification = false, groupLabel?: string) => { +export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string, isFromNotification = false, groupLabel?: RequestGroupLabel) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; @@ -214,7 +214,7 @@ export const fetchThreads = async ( options: FetchThreadsOptions, direction?: Direction, pages?: number, - groupLabel?: string, + groupLabel?: RequestGroupLabel, ) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { @@ -287,7 +287,7 @@ export const fetchThreads = async ( export const syncThreadsIfNeeded = async ( serverUrl: string, isCRTEnabled: boolean, teams?: Team[], - fetchOnly = false, groupLabel?: string, + fetchOnly = false, groupLabel?: RequestGroupLabel, ) => { try { if (!isCRTEnabled) { @@ -329,7 +329,7 @@ type SyncThreadOptions = { excludeDirect?: boolean; fetchOnly?: boolean; refresh?: boolean; - groupLabel?: string; + groupLabel?: RequestGroupLabel; } export const syncTeamThreads = async ( diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 5b1025948..66836827d 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -43,7 +43,7 @@ export type ProfilesInChannelRequest = { error?: unknown; } -export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?: string): Promise => { +export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise => { try { const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -98,7 +98,7 @@ export const refetchCurrentUser = async (serverUrl: string, currentUserId: strin export async function fetchProfilesInChannel( serverUrl: string, channelId: string, excludeUserId?: string, options?: GetUsersOptions, - fetchOnly = false, groupLabel?: string, + fetchOnly = false, groupLabel?: RequestGroupLabel, ): Promise { try { const client = NetworkManager.getClient(serverUrl); @@ -134,7 +134,7 @@ export async function fetchProfilesInChannel( } } -export async function fetchProfilesInGroupChannels(serverUrl: string, groupChannelIds: string[], fetchOnly = false, groupLabel?: string): Promise { +export async function fetchProfilesInGroupChannels(serverUrl: string, groupChannelIds: string[], fetchOnly = false, groupLabel?: RequestGroupLabel): Promise { try { const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -199,7 +199,7 @@ export async function fetchProfilesInGroupChannels(serverUrl: string, groupChann export async function fetchProfilesPerChannels( serverUrl: string, channelIds: string[], excludeUserId?: string, - fetchOnly = false, groupLabel?: string, + fetchOnly = false, groupLabel?: RequestGroupLabel, ): Promise { try { const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -250,7 +250,7 @@ export async function fetchProfilesPerChannels( } } -export const updateMe = async (serverUrl: string, user: Partial, groupLabel?: string) => { +export const updateMe = async (serverUrl: string, user: Partial, groupLabel?: RequestGroupLabel) => { try { const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -428,7 +428,7 @@ export const fetchUserByIdBatched = async (serverUrl: string, userId: string) => usersByIdBatch.timeout = setTimeout(processBatch, TIME_TO_BATCH); }; -export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetchOnly = false, groupLabel?: string) => { +export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetchOnly = false, groupLabel?: RequestGroupLabel) => { if (!userIds.length) { return {users: [], existingUsers: []}; } @@ -632,7 +632,7 @@ export const fetchMissingProfilesByUsernames = async (serverUrl: string, usernam return {users}; }; -export async function updateAllUsersSince(serverUrl: string, since: number, fetchOnly = false, groupLabel?: string) { +export async function updateAllUsersSince(serverUrl: string, since: number, fetchOnly = false, groupLabel?: RequestGroupLabel) { if (!since) { return {users: []}; } @@ -807,7 +807,7 @@ export const buildProfileImageUrlFromUser = (serverUrl: string, user: UserModel return buildProfileImageUrl(serverUrl, user.id, lastPictureUpdate); }; -export const autoUpdateTimezone = async (serverUrl: string, groupLabel?: string) => { +export const autoUpdateTimezone = async (serverUrl: string, groupLabel?: RequestGroupLabel) => { let database; try { const result = DatabaseManager.getServerDatabaseAndOperator(serverUrl); diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index acc1be776..dfa6121a3 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -36,17 +36,17 @@ import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; import {logDebug, logInfo} from '@utils/log'; -export async function handleFirstConnect(serverUrl: string, groupLabel?: string) { +export async function handleFirstConnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel) { setExtraSessionProps(serverUrl, groupLabel); autoUpdateTimezone(serverUrl, groupLabel); return doReconnect(serverUrl, groupLabel); } -export async function handleReconnect(serverUrl: string) { - return doReconnect(serverUrl, 'reconnection'); +export async function handleReconnect(serverUrl: string, groupLabel: BaseRequestGroupLabel = 'WebSocket Reconnect') { + return doReconnect(serverUrl, groupLabel); } -async function doReconnect(serverUrl: string, groupLabel?: string) { +async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return new Error('cannot find server database'); @@ -82,13 +82,6 @@ async function doReconnect(serverUrl: string, groupLabel?: string) { await setLastFullSync(operator, now); - const tabletDevice = isTablet(); - const isActiveServer = (await getActiveServerUrl()) === serverUrl; - if (isActiveServer && tabletDevice && initialChannelId === currentChannelId) { - await markChannelAsRead(serverUrl, initialChannelId, false, groupLabel); - markChannelAsViewed(serverUrl, initialChannelId); - } - logInfo('WEBSOCKET RECONNECT MODELS BATCHING TOOK', `${Date.now() - dt}ms`); setTeamLoading(serverUrl, false); @@ -112,7 +105,7 @@ async function doReconnect(serverUrl: string, groupLabel?: string) { return undefined; } -async function fetchPostDataIfNeeded(serverUrl: string, groupLabel?: string) { +async function fetchPostDataIfNeeded(serverUrl: string, groupLabel?: RequestGroupLabel) { try { const isActiveServer = (await getActiveServerUrl()) === serverUrl; if (!isActiveServer) { @@ -146,7 +139,7 @@ async function fetchPostDataIfNeeded(serverUrl: string, groupLabel?: string) { } if (currentChannelId && (isChannelScreenMounted || tabletDevice)) { - await fetchPostsForChannel(serverUrl, currentChannelId, false, groupLabel); + await fetchPostsForChannel(serverUrl, currentChannelId, false, false, groupLabel); markChannelAsRead(serverUrl, currentChannelId, false, groupLabel); if (!EphemeralStore.wasNotificationTapped()) { markChannelAsViewed(serverUrl, currentChannelId, true); diff --git a/app/client/rest/apps.ts b/app/client/rest/apps.ts index 12f705d92..b71397f25 100644 --- a/app/client/rest/apps.ts +++ b/app/client/rest/apps.ts @@ -7,7 +7,7 @@ import type ClientBase from './base'; export interface ClientAppsMix { executeAppCall: (call: AppCallRequest, trackAsSubmit: boolean) => Promise>; - getAppsBindings: (userID: string, channelID: string, teamID: string, groupLabel?: string) => Promise; + getAppsBindings: (userID: string, channelID: string, teamID: string, groupLabel?: RequestGroupLabel) => Promise; } const ClientApps = >(superclass: TBase) => class extends superclass { @@ -27,7 +27,7 @@ const ClientApps = >(superclass: TBase) => ); }; - getAppsBindings = async (userID: string, channelID: string, teamID: string, groupLabel?: string) => { + getAppsBindings = async (userID: string, channelID: string, teamID: string, groupLabel?: RequestGroupLabel) => { const params = { user_id: userID, channel_id: channelID, diff --git a/app/client/rest/categories.ts b/app/client/rest/categories.ts index fd1485573..e4d720c95 100644 --- a/app/client/rest/categories.ts +++ b/app/client/rest/categories.ts @@ -4,14 +4,14 @@ import type ClientBase from './base'; export interface ClientCategoriesMix { - getCategories: (userId: string, teamId: string, groupLabel?: string) => Promise; + getCategories: (userId: string, teamId: string, groupLabel?: RequestGroupLabel) => Promise; getCategoriesOrder: (userId: string, teamId: string) => Promise; getCategory: (userId: string, teamId: string, categoryId: string) => Promise; updateChannelCategories: (userId: string, teamId: string, categories: CategoryWithChannels[]) => Promise; } const ClientCategories = >(superclass: TBase) => class extends superclass { - getCategories = async (userId: string, teamId: string, groupLabel?: string) => { + getCategories = async (userId: string, teamId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getCategoriesRoute(userId, teamId)}`, {method: 'get', groupLabel}, diff --git a/app/client/rest/channel_bookmark.test.ts b/app/client/rest/channel_bookmark.test.ts index c5be4d52d..49888a42d 100644 --- a/app/client/rest/channel_bookmark.test.ts +++ b/app/client/rest/channel_bookmark.test.ts @@ -81,7 +81,7 @@ describe('ClientChannelBookmarks', () => { test('getChannelBookmarksForChannel', async () => { const channelId = 'channel_id'; const since = 123456; - const groupLabel = 'group_label'; + const groupLabel = 'Login'; const expectedUrl = `${client.getChannelBookmarksRoute(channelId)}${buildQueryString({bookmarks_since: since})}`; const expectedOptions = {method: 'get', groupLabel}; diff --git a/app/client/rest/channel_bookmark.ts b/app/client/rest/channel_bookmark.ts index 39e866575..3aa568da1 100644 --- a/app/client/rest/channel_bookmark.ts +++ b/app/client/rest/channel_bookmark.ts @@ -10,7 +10,7 @@ export interface ClientChannelBookmarksMix { updateChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string): Promise; updateChannelBookmarkSortOrder(channelId: string, bookmarkId: string, newSortOrder: number, connectionId?: string): Promise; deleteChannelBookmark(channelId: string, bookmarkId: string, connectionId?: string): Promise; - getChannelBookmarksForChannel(channelId: string, since: number, groupLabel?: string): Promise; + getChannelBookmarksForChannel(channelId: string, since: number, groupLabel?: RequestGroupLabel): Promise; } const ClientChannelBookmarks = >(superclass: TBase) => class extends superclass { @@ -57,7 +57,7 @@ const ClientChannelBookmarks = >(superclas ); } - getChannelBookmarksForChannel(channelId: string, since: number, groupLabel?: string) { + getChannelBookmarksForChannel(channelId: string, since: number, groupLabel?: RequestGroupLabel) { return this.doFetch( `${this.getChannelBookmarksRoute(channelId)}${buildQueryString({bookmarks_since: since})}`, {method: 'get', groupLabel}, diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 6900d1dfb..9a05661b0 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -19,24 +19,24 @@ export interface ClientChannelsMix { updateChannelPrivacy: (channelId: string, privacy: any) => Promise; patchChannel: (channelId: string, channelPatch: Partial) => Promise; updateChannelNotifyProps: (props: ChannelNotifyProps & {channel_id: string; user_id: string}) => Promise; - getChannel: (channelId: string, groupLabel?: string) => Promise; + getChannel: (channelId: string, groupLabel?: RequestGroupLabel) => Promise; getChannelByName: (teamId: string, channelName: string, includeDeleted?: boolean) => Promise; getChannelByNameAndTeamName: (teamName: string, channelName: string, includeDeleted?: boolean) => Promise; getChannels: (teamId: string, page?: number, perPage?: number) => Promise; getArchivedChannels: (teamId: string, page?: number, perPage?: number) => Promise; getSharedChannels: (teamId: string, page?: number, perPage?: number) => Promise; - getMyChannels: (teamId: string, includeDeleted?: boolean, lastDeleteAt?: number, groupLabel?: string) => Promise; + getMyChannels: (teamId: string, includeDeleted?: boolean, lastDeleteAt?: number, groupLabel?: RequestGroupLabel) => Promise; getMyChannelMember: (channelId: string) => Promise; - getMyChannelMembers: (teamId: string, groupLabel?: string) => Promise; + getMyChannelMembers: (teamId: string, groupLabel?: RequestGroupLabel) => Promise; getChannelMembers: (channelId: string, page?: number, perPage?: number) => Promise; getChannelTimezones: (channelId: string) => Promise; - getChannelMember: (channelId: string, userId: string, groupLabel?: string) => Promise; + getChannelMember: (channelId: string, userId: string, groupLabel?: RequestGroupLabel) => Promise; getChannelMembersByIds: (channelId: string, userIds: string[]) => Promise; addToChannel: (userId: string, channelId: string, postRootId?: string) => Promise; removeFromChannel: (userId: string, channelId: string) => Promise; - getChannelStats: (channelId: string, groupLabel?: string) => Promise; + getChannelStats: (channelId: string, groupLabel?: RequestGroupLabel) => Promise; getChannelMemberCountsByGroup: (channelId: string, includeTimezones: boolean) => Promise; - viewMyChannel: (channelId: string, prevChannelId?: string, groupLabel?: string) => Promise; + viewMyChannel: (channelId: string, prevChannelId?: string, groupLabel?: RequestGroupLabel) => Promise; autocompleteChannels: (teamId: string, name: string) => Promise; autocompleteChannelsForSearch: (teamId: string, name: string) => Promise; searchChannels: (teamId: string, term: string) => Promise; @@ -46,6 +46,8 @@ export interface ClientChannelsMix { getMemberInChannel: (channelId: string, userId: string) => Promise; getGroupMessageMembersCommonTeams: (channelId: string) => Promise; convertGroupMessageToPrivateChannel: (channelId: string, teamId: string, displayName: string, name: string) => Promise; + getAllChannelsFromAllTeams: (lastDeleteAt: number, includeDeleted: boolean, groupLabel?: RequestGroupLabel) => Promise; + getAllMyChannelMembersFromAllTeams: (page: number, perPage: number, groupLabel?: RequestGroupLabel) => Promise; } const ClientChannels = >(superclass: TBase) => class extends superclass { @@ -63,6 +65,30 @@ const ClientChannels = >(superclass: TBase ); }; + getAllChannelsFromAllTeams = async (lastDeleteAt: number, includeDeleted: boolean, groupLabel?: RequestGroupLabel) => { + const queryData = { + last_delete_at: lastDeleteAt, + include_deleted: includeDeleted, + }; + + return this.doFetch( + `${this.getUserRoute('me')}/channels${buildQueryString(queryData)}`, + {method: 'get', groupLabel}, + ); + }; + + getAllMyChannelMembersFromAllTeams = async (page: number, perPage: number, groupLabel?: RequestGroupLabel) => { + const queryData = { + page, + per_page: perPage, + }; + + return this.doFetch( + `${this.getUserRoute('me')}/channel_members${buildQueryString(queryData)}`, + {method: 'get', groupLabel}, + ); + }; + createChannel = async (channel: Channel) => { return this.doFetch( `${this.getChannelsRoute()}`, @@ -130,7 +156,7 @@ const ClientChannels = >(superclass: TBase ); }; - getChannel = async (channelId: string, groupLabel?: string) => { + getChannel = async (channelId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( this.getChannelRoute(channelId), {method: 'get', groupLabel}, @@ -180,7 +206,7 @@ const ClientChannels = >(superclass: TBase ); }; - getMyChannels = async (teamId: string, includeDeleted = false, since = 0, groupLabel?: string) => { + getMyChannels = async (teamId: string, includeDeleted = false, since = 0, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUserRoute('me')}/teams/${teamId}/channels${buildQueryString({ include_deleted: includeDeleted, @@ -197,7 +223,7 @@ const ClientChannels = >(superclass: TBase ); }; - getMyChannelMembers = async (teamId: string, groupLabel?: string) => { + getMyChannelMembers = async (teamId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUserRoute('me')}/teams/${teamId}/channels/members`, {method: 'get', groupLabel}, @@ -218,7 +244,7 @@ const ClientChannels = >(superclass: TBase ); }; - getChannelMember = async (channelId: string, userId: string, groupLabel?: string) => { + getChannelMember = async (channelId: string, userId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getChannelMemberRoute(channelId, userId)}`, {method: 'get', groupLabel}, @@ -247,7 +273,7 @@ const ClientChannels = >(superclass: TBase ); }; - getChannelStats = async (channelId: string, groupLabel?: string) => { + getChannelStats = async (channelId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getChannelRoute(channelId)}/stats`, {method: 'get', groupLabel}, @@ -261,7 +287,7 @@ const ClientChannels = >(superclass: TBase ); }; - viewMyChannel = async (channelId: string, prevChannelId?: string, groupLabel?: string) => { + viewMyChannel = async (channelId: string, prevChannelId?: string, groupLabel?: RequestGroupLabel) => { // collapsed_threads_supported is not based on user preferences but to know if "CLIENT" supports CRT const data = {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true}; return this.doFetch( diff --git a/app/client/rest/general.test.ts b/app/client/rest/general.test.ts index 86af1068c..16ed091ab 100644 --- a/app/client/rest/general.test.ts +++ b/app/client/rest/general.test.ts @@ -21,7 +21,7 @@ describe('ClientGeneral', () => { test('ping', async () => { const deviceId = 'device1'; const timeoutInterval = 1000; - const groupLabel = 'group1'; + const groupLabel = 'DeepLink'; const expectedUrl = `${client.urlVersion}/system/ping?time=${Date.now()}&device_id=${deviceId}`; const expectedOptions = {method: 'get', timeoutInterval, groupLabel}; @@ -59,7 +59,7 @@ describe('ClientGeneral', () => { }); test('getClientConfigOld', async () => { - const groupLabel = 'group1'; + const groupLabel = 'Notification'; const expectedUrl = `${client.urlVersion}/config/client?format=old`; const expectedOptions = {method: 'get', groupLabel}; @@ -69,7 +69,7 @@ describe('ClientGeneral', () => { }); test('getClientLicenseOld', async () => { - const groupLabel = 'group1'; + const groupLabel = 'Notification'; const expectedUrl = `${client.urlVersion}/license/client?format=old`; const expectedOptions = {method: 'get', groupLabel}; @@ -88,7 +88,7 @@ describe('ClientGeneral', () => { }); test('getGlobalDataRetentionPolicy', async () => { - const groupLabel = 'group1'; + const groupLabel = 'Notification'; const expectedUrl = `${client.getGlobalDataRetentionRoute()}/policy`; const expectedOptions = {method: 'get', groupLabel}; @@ -101,7 +101,7 @@ describe('ClientGeneral', () => { const userId = 'user1'; const page = 1; const perPage = 10; - const groupLabel = 'group1'; + const groupLabel = 'Notification'; const expectedUrl = `${client.getGranularDataRetentionRoute(userId)}/team_policies${buildQueryString({page, per_page: perPage})}`; const expectedOptions = {method: 'get', groupLabel}; @@ -118,7 +118,7 @@ describe('ClientGeneral', () => { const userId = 'user1'; const page = 1; const perPage = 10; - const groupLabel = 'group1'; + const groupLabel = 'Notification'; const expectedUrl = `${client.getGranularDataRetentionRoute(userId)}/channel_policies${buildQueryString({page, per_page: perPage})}`; const expectedOptions = {method: 'get', groupLabel}; @@ -133,7 +133,7 @@ describe('ClientGeneral', () => { test('getRolesByNames', async () => { const rolesNames = ['role1', 'role2']; - const groupLabel = 'group1'; + const groupLabel = 'Notification'; const expectedUrl = `${client.getRolesRoute()}/names`; const expectedOptions = {method: 'post', body: rolesNames, groupLabel}; diff --git a/app/client/rest/general.ts b/app/client/rest/general.ts index 943144005..ff60ec0b7 100644 --- a/app/client/rest/general.ts +++ b/app/client/rest/general.ts @@ -14,21 +14,21 @@ type PoliciesResponse = { } export interface ClientGeneralMix { - ping: (deviceId?: string, timeoutInterval?: number, groupLabel?: string) => Promise; + ping: (deviceId?: string, timeoutInterval?: number, groupLabel?: RequestGroupLabel) => Promise; logClientError: (message: string, level?: string) => Promise; - getClientConfigOld: (groupLabel?: string) => Promise; - getClientLicenseOld: (groupLabel?: string) => Promise; + getClientConfigOld: (groupLabel?: RequestGroupLabel) => Promise; + getClientLicenseOld: (groupLabel?: RequestGroupLabel) => Promise; getTimezones: () => Promise; - getGlobalDataRetentionPolicy: (groupLabel?: string) => Promise; - getTeamDataRetentionPolicies: (userId: string, page?: number, perPage?: number, groupLabel?: string) => Promise>; - getChannelDataRetentionPolicies: (userId: string, page?: number, perPage?: number, groupLabel?: string) => Promise>; - getRolesByNames: (rolesNames: string[], groupLabel?: string) => Promise; + getGlobalDataRetentionPolicy: (groupLabel?: RequestGroupLabel) => Promise; + getTeamDataRetentionPolicies: (userId: string, page?: number, perPage?: number, groupLabel?: RequestGroupLabel) => Promise>; + getChannelDataRetentionPolicies: (userId: string, page?: number, perPage?: number, groupLabel?: RequestGroupLabel) => Promise>; + getRolesByNames: (rolesNames: string[], groupLabel?: RequestGroupLabel) => Promise; getRedirectLocation: (urlParam: string) => Promise>; sendPerformanceReport: (batch: PerformanceReport) => Promise<{}>; } const ClientGeneral = >(superclass: TBase) => class extends superclass { - ping = async (deviceId?: string, timeoutInterval?: number, groupLabel?: string) => { + ping = async (deviceId?: string, timeoutInterval?: number, groupLabel?: RequestGroupLabel) => { let url = `${this.urlVersion}/system/ping?time=${Date.now()}`; if (deviceId) { url = `${url}&device_id=${deviceId}`; @@ -56,14 +56,14 @@ const ClientGeneral = >(superclass: TBase) ); }; - getClientConfigOld = async (groupLabel?: string) => { + getClientConfigOld = async (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.urlVersion}/config/client?format=old`, {method: 'get', groupLabel}, ); }; - getClientLicenseOld = async (groupLabel?: string) => { + getClientLicenseOld = async (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.urlVersion}/license/client?format=old`, {method: 'get', groupLabel}, @@ -77,28 +77,28 @@ const ClientGeneral = >(superclass: TBase) ); }; - getGlobalDataRetentionPolicy = (groupLabel?: string) => { + getGlobalDataRetentionPolicy = (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getGlobalDataRetentionRoute()}/policy`, {method: 'get', groupLabel}, ); }; - getTeamDataRetentionPolicies = (userId: string, page = 0, perPage = PER_PAGE_DEFAULT, groupLabel?: string) => { + getTeamDataRetentionPolicies = (userId: string, page = 0, perPage = PER_PAGE_DEFAULT, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getGranularDataRetentionRoute(userId)}/team_policies${buildQueryString({page, per_page: perPage})}`, {method: 'get', groupLabel}, ); }; - getChannelDataRetentionPolicies = (userId: string, page = 0, perPage = PER_PAGE_DEFAULT, groupLabel?: string) => { + getChannelDataRetentionPolicies = (userId: string, page = 0, perPage = PER_PAGE_DEFAULT, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getGranularDataRetentionRoute(userId)}/channel_policies${buildQueryString({page, per_page: perPage})}`, {method: 'get', groupLabel}, ); }; - getRolesByNames = async (rolesNames: string[], groupLabel?: string) => { + getRolesByNames = async (rolesNames: string[], groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getRolesRoute()}/names`, {method: 'post', body: rolesNames, groupLabel}, diff --git a/app/client/rest/groups.test.ts b/app/client/rest/groups.test.ts index 6985f651a..24135371c 100644 --- a/app/client/rest/groups.test.ts +++ b/app/client/rest/groups.test.ts @@ -58,7 +58,7 @@ describe('ClientGroups', () => { test('getAllGroupsAssociatedToChannel', async () => { const channelId = 'channel1'; - const groupLabel = 'testGroup'; + const groupLabel = 'WebSocket Reconnect'; await client.getAllGroupsAssociatedToChannel(channelId, undefined, groupLabel); @@ -88,7 +88,7 @@ describe('ClientGroups', () => { test('getAllGroupsAssociatedToMembership', async () => { const userId = 'user1'; - const groupLabel = 'testGroup'; + const groupLabel = 'WebSocket Reconnect'; await client.getAllGroupsAssociatedToMembership(userId, undefined, groupLabel); diff --git a/app/client/rest/groups.ts b/app/client/rest/groups.ts index c1b68286b..f950e089d 100644 --- a/app/client/rest/groups.ts +++ b/app/client/rest/groups.ts @@ -9,8 +9,8 @@ import type ClientBase from './base'; export interface ClientGroupsMix { getGroups: (params: {query?: string; filterAllowReference?: boolean; page?: number; perPage?: number; since?: number; includeMemberCount?: boolean}) => Promise; - getAllGroupsAssociatedToChannel: (channelId: string, filterAllowReference?: boolean, groupLabel?: string) => Promise<{groups: Group[]; total_group_count: number}>; - getAllGroupsAssociatedToMembership: (userId: string, filterAllowReference?: boolean, groupLabel?: string) => Promise; + getAllGroupsAssociatedToChannel: (channelId: string, filterAllowReference?: boolean, groupLabel?: RequestGroupLabel) => Promise<{groups: Group[]; total_group_count: number}>; + getAllGroupsAssociatedToMembership: (userId: string, filterAllowReference?: boolean, groupLabel?: RequestGroupLabel) => Promise; getAllGroupsAssociatedToTeam: (teamId: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>; } @@ -29,7 +29,7 @@ const ClientGroups = >(superclass: TBase) ); }; - getAllGroupsAssociatedToChannel = async (channelId: string, filterAllowReference = false, groupLabel?: string) => { + getAllGroupsAssociatedToChannel = async (channelId: string, filterAllowReference = false, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.urlVersion}/channels/${channelId}/groups${buildQueryString({ paginate: false, @@ -47,7 +47,7 @@ const ClientGroups = >(superclass: TBase) ); }; - getAllGroupsAssociatedToMembership = async (userId: string, filterAllowReference = false, groupLabel?: string) => { + getAllGroupsAssociatedToMembership = async (userId: string, filterAllowReference = false, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.urlVersion}/users/${userId}/groups${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`, {method: 'get', groupLabel}, diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index f7b2c8566..5635ca5b3 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -10,12 +10,12 @@ import type ClientBase from './base'; export interface ClientPostsMix { createPost: (post: Post) => Promise; updatePost: (post: Post) => Promise; - getPost: (postId: string, groupLabel?: string) => Promise; + getPost: (postId: string, groupLabel?: RequestGroupLabel) => Promise; patchPost: (postPatch: Partial & {id: string}) => Promise; deletePost: (postId: string) => Promise; - getPostThread: (postId: string, options: FetchPaginatedThreadOptions, groupLabel?: string) => Promise; - getPosts: (channelId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean, groupLabel?: string) => Promise; - getPostsSince: (channelId: string, since: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean, groupLabel?: string) => Promise; + getPostThread: (postId: string, options: FetchPaginatedThreadOptions, groupLabel?: RequestGroupLabel) => Promise; + getPosts: (channelId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean, groupLabel?: RequestGroupLabel) => Promise; + getPostsSince: (channelId: string, since: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean, groupLabel?: RequestGroupLabel) => Promise; getPostsBefore: (channelId: string, postId?: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; getFileInfosForPost: (postId: string) => Promise; @@ -51,7 +51,7 @@ const ClientPosts = >(superclass: TBase) = ); }; - getPost = async (postId: string, groupLabel?: string) => { + getPost = async (postId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getPostRoute(postId)}`, {method: 'get', groupLabel}, @@ -72,7 +72,7 @@ const ClientPosts = >(superclass: TBase) = ); }; - getPostThread = (postId: string, options: FetchPaginatedThreadOptions, groupLabel?: string) => { + getPostThread = (postId: string, options: FetchPaginatedThreadOptions, groupLabel?: RequestGroupLabel) => { const { fetchThreads = true, collapsedThreads = false, @@ -88,14 +88,14 @@ const ClientPosts = >(superclass: TBase) = ); }; - getPosts = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT, collapsedThreads = false, collapsedThreadsExtended = false, groupLabel?: string) => { + getPosts = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT, collapsedThreads = false, collapsedThreadsExtended = false, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getChannelRoute(channelId)}/posts${buildQueryString({page, per_page: perPage, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get', groupLabel}, ); }; - getPostsSince = async (channelId: string, since: number, collapsedThreads = false, collapsedThreadsExtended = false, groupLabel?: string) => { + getPostsSince = async (channelId: string, since: number, collapsedThreads = false, collapsedThreadsExtended = false, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getChannelRoute(channelId)}/posts${buildQueryString({since, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get', groupLabel}, diff --git a/app/client/rest/preferences.test.ts b/app/client/rest/preferences.test.ts index 1993172a5..19dbe561d 100644 --- a/app/client/rest/preferences.test.ts +++ b/app/client/rest/preferences.test.ts @@ -17,7 +17,7 @@ describe('ClientPreferences', () => { test('savePreferences', async () => { const userId = 'user_id'; const preferences = [{category: 'category1', name: 'name1', value: 'value1'}] as PreferenceType[]; - const groupLabel = 'group_label'; + const groupLabel = 'Server Switch'; const expectedUrl = client.getPreferencesRoute(userId); const expectedOptions = { method: 'put', @@ -31,7 +31,7 @@ describe('ClientPreferences', () => { }); test('getMyPreferences', async () => { - const groupLabel = 'group_label'; + const groupLabel = 'Server Switch'; const expectedUrl = client.getPreferencesRoute('me'); const expectedOptions = { method: 'get', diff --git a/app/client/rest/preferences.ts b/app/client/rest/preferences.ts index dbb40183e..1198daed4 100644 --- a/app/client/rest/preferences.ts +++ b/app/client/rest/preferences.ts @@ -4,20 +4,20 @@ import type ClientBase from './base'; export interface ClientPreferencesMix { - savePreferences: (userId: string, preferences: PreferenceType[], groupLabel?: string) => Promise; + savePreferences: (userId: string, preferences: PreferenceType[], groupLabel?: RequestGroupLabel) => Promise; deletePreferences: (userId: string, preferences: PreferenceType[]) => Promise; - getMyPreferences: (groupLabel?: string) => Promise; + getMyPreferences: (groupLabel?: RequestGroupLabel) => Promise; } const ClientPreferences = >(superclass: TBase) => class extends superclass { - savePreferences = async (userId: string, preferences: PreferenceType[], groupLabel?: string) => { + savePreferences = async (userId: string, preferences: PreferenceType[], groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getPreferencesRoute(userId)}`, {method: 'put', body: preferences, groupLabel}, ); }; - getMyPreferences = async (groupLabel?: string) => { + getMyPreferences = async (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getPreferencesRoute('me')}`, {method: 'get', groupLabel}, diff --git a/app/client/rest/teams.ts b/app/client/rest/teams.ts index c2136b256..f290124d5 100644 --- a/app/client/rest/teams.ts +++ b/app/client/rest/teams.ts @@ -12,14 +12,14 @@ export interface ClientTeamsMix { deleteTeam: (teamId: string) => Promise; updateTeam: (team: Team) => Promise; patchTeam: (team: Partial & {id: string}) => Promise; - getTeams: (page?: number, perPage?: number, includeTotalCount?: boolean, groupLabel?: string) => Promise; - getTeam: (teamId: string, groupLabel?: string) => Promise; + getTeams: (page?: number, perPage?: number, includeTotalCount?: boolean, groupLabel?: RequestGroupLabel) => Promise; + getTeam: (teamId: string, groupLabel?: RequestGroupLabel) => Promise; getTeamByName: (teamName: string) => Promise; - getMyTeams: (groupLabel?: string) => Promise; + getMyTeams: (groupLabel?: RequestGroupLabel) => Promise; getTeamsForUser: (userId: string) => Promise; - getMyTeamMembers: (groupLabel?: string) => Promise; + getMyTeamMembers: (groupLabel?: RequestGroupLabel) => Promise; getTeamMembers: (teamId: string, page?: number, perPage?: number) => Promise; - getTeamMember: (teamId: string, userId: string, groupLabel?: string) => Promise; + getTeamMember: (teamId: string, userId: string, groupLabel?: RequestGroupLabel) => Promise; getTeamMembersByIds: (teamId: string, userIds: string[]) => Promise; addToTeam: (teamId: string, userId: string) => Promise; addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise; @@ -59,14 +59,14 @@ const ClientTeams = >(superclass: TBase) = ); }; - getTeams = async (page = 0, perPage = PER_PAGE_DEFAULT, includeTotalCount = false, groupLabel?: string) => { + getTeams = async (page = 0, perPage = PER_PAGE_DEFAULT, includeTotalCount = false, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getTeamsRoute()}${buildQueryString({page, per_page: perPage, include_total_count: includeTotalCount})}`, {method: 'get', groupLabel}, ); }; - getTeam = async (teamId: string, groupLabel?: string) => { + getTeam = async (teamId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( this.getTeamRoute(teamId), {method: 'get', groupLabel}, @@ -80,7 +80,7 @@ const ClientTeams = >(superclass: TBase) = ); }; - getMyTeams = async (groupLabel?: string) => { + getMyTeams = async (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUserRoute('me')}/teams`, {method: 'get', groupLabel}, @@ -94,7 +94,7 @@ const ClientTeams = >(superclass: TBase) = ); }; - getMyTeamMembers = async (groupLabel?: string) => { + getMyTeamMembers = async (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUserRoute('me')}/teams/members`, {method: 'get', groupLabel}, @@ -108,7 +108,7 @@ const ClientTeams = >(superclass: TBase) = ); }; - getTeamMember = async (teamId: string, userId: string, groupLabel?: string) => { + getTeamMember = async (teamId: string, userId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getTeamMemberRoute(teamId, userId)}`, {method: 'get', groupLabel}, diff --git a/app/client/rest/threads.test.ts b/app/client/rest/threads.test.ts index b28ada6be..963d8d978 100644 --- a/app/client/rest/threads.test.ts +++ b/app/client/rest/threads.test.ts @@ -31,7 +31,7 @@ describe('ClientThreads', () => { const totalsOnly = true; const serverVersion = '6.0.0'; const excludeDirect = true; - const groupLabel = 'group1'; + const groupLabel = 'Cold Start'; const queryStringObj = { extended: 'true', before, diff --git a/app/client/rest/threads.ts b/app/client/rest/threads.ts index 4239f156b..ebf1f583b 100644 --- a/app/client/rest/threads.ts +++ b/app/client/rest/threads.ts @@ -12,7 +12,7 @@ export interface ClientThreadsMix { userId: string, teamId: string, before?: string, after?: string, pageSize?: number, deleted?: boolean, unread?: boolean, since?: number, totalsOnly?: boolean, serverVersion?: string, - excludeDirect?: boolean, groupLabel?: string, + excludeDirect?: boolean, groupLabel?: RequestGroupLabel, ) => Promise; getThread: (userId: string, teamId: string, threadId: string, extended?: boolean) => Promise; markThreadAsRead: (userId: string, teamId: string, threadId: string, timestamp: number) => Promise; @@ -26,7 +26,7 @@ const ClientThreads = >(superclass: TBase) userId: string, teamId: string, before = '', after = '', pageSize = PER_PAGE_DEFAULT, deleted = false, unread = false, since = 0, totalsOnly = false, serverVersion = '', - excludeDirect = false, groupLabel?: string) => { + excludeDirect = false, groupLabel?: RequestGroupLabel) => { const queryStringObj: Record = { extended: 'true', before, diff --git a/app/client/rest/tracking.test.ts b/app/client/rest/tracking.test.ts index 924ba3b6d..99dd20fa7 100644 --- a/app/client/rest/tracking.test.ts +++ b/app/client/rest/tracking.test.ts @@ -1,13 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ +import {DeviceEventEmitter} from 'react-native'; + +import LocalConfig from '@assets/config.json'; +import {Events} from '@constants'; import test_helper from '@test/test_helper'; import * as ClientConstants from './constants'; -import ClientTracking from './tracking'; +import ClientTracking, {testExports} from './tracking'; +import type ClientError from './error'; import type {APIClientInterface, ClientResponseMetrics} from '@mattermost/react-native-network-client'; +type ParallelGroup = typeof testExports.ParallelGroup; + jest.mock('react-native', () => ({ DeviceEventEmitter: { emit: jest.fn(), @@ -50,7 +58,11 @@ jest.mock('@init/credentials', () => ({ setServerCredentials: jest.fn(), })); -describe('ClientTraking', () => { +jest.mock('@managers/performance_metrics_manager', () => ({ + collectNetworkRequestData: jest.fn(), +})); + +describe('ClientTracking', () => { const apiClientMock = { baseUrl: 'https://example.com', get: jest.fn(), @@ -62,11 +74,22 @@ describe('ClientTraking', () => { let client: ClientTracking; + beforeAll(() => { + LocalConfig.CollectNetworkMetrics = true; + }); + beforeEach(() => { - jest.clearAllMocks(); client = new ClientTracking(apiClientMock as unknown as APIClientInterface); }); + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + LocalConfig.CollectNetworkMetrics = false; + }); + it('should set bearer token', () => { const token = 'testToken'; client.setBearerToken(token); @@ -92,60 +115,73 @@ describe('ClientTraking', () => { }); it('should initialize and track group data', () => { - client.initTrackGroup('testGroup'); - expect(client.requestGroups.has('testGroup')).toBe(true); + client.initTrackGroup('Cold Start'); + expect(client.requestGroups.has('Cold Start')).toBe(true); - client.trackRequest('testGroup', 'https://example.com/api', {size: 100, compressedSize: 50} as ClientResponseMetrics); - const group = client.requestGroups.get('testGroup')!; + client.trackRequest('Cold Start', 'https://example.com/api', {size: 100, compressedSize: 50} as ClientResponseMetrics); + const group = client.requestGroups.get('Cold Start')!; expect(group.totalSize).toBe(100); expect(group.totalCompressedSize).toBe(50); expect(group.urls['https://example.com/api'].count).toBe(1); }); it('should increment and decrement request count', () => { - client.initTrackGroup('testGroup'); - client.incrementRequestCount('testGroup'); - let group = client.requestGroups.get('testGroup')!; + client.initTrackGroup('Cold Start'); + client.incrementRequestCount('Cold Start'); + let group = client.requestGroups.get('Cold Start')!; expect(group.activeCount).toBe(1); - client.decrementRequestCount('testGroup'); - group = client.requestGroups.get('testGroup')!; + client.decrementRequestCount('Cold Start'); + group = client.requestGroups.get('Cold Start')!; expect(group.activeCount).toBe(0); }); it('should handle request completion', () => { - client.initTrackGroup('testGroup'); - client.handleRequestCompletion('testGroup'); + client.initTrackGroup('Cold Start'); + client.handleRequestCompletion('Cold Start'); - expect(require('@utils/log').logDebug).toHaveBeenCalledWith('Group "testGroup" completed.'); + expect(require('@utils/log').logDebug).toHaveBeenCalledWith('Group "Cold Start" completed.'); }); it('should clear completion timer', () => { - client.initTrackGroup('testGroup'); - const group = client.requestGroups.get('testGroup')!; + client.initTrackGroup('Cold Start'); + const group = client.requestGroups.get('Cold Start')!; group.completionTimer = setTimeout(() => {}, 100); - client.clearCompletionTimer('testGroup'); + client.clearCompletionTimer('Cold Start'); expect(group.completionTimer).toBeUndefined(); }); it('should check if all requests are completed', () => { - client.initTrackGroup('testGroup'); - const result = client.allRequestsCompleted('testGroup'); + client.initTrackGroup('Cold Start'); + const result = client.allRequestsCompleted('Cold Start'); expect(result).toBe(true); }); it('should send telemetry event', () => { - client.initTrackGroup('testGroup'); - const group = client.requestGroups.get('testGroup')!; + client.initTrackGroup('Cold Start'); + const group = client.requestGroups.get('Cold Start')!; group.urls = { 'https://example.com/api': { count: 2, - metrics: {latency: 200, networkType: 'Wi-Fi', tlsCipherSuite: 'none', tlsVersion: 'none', isCached: false, httpVersion: 'h2', compressedSize: 10 * 1024, size: 6 * 1024 * 1024, connectionTime: 0, startTime: 0, endTime: 100, speedInMbps: 1}, + metrics: { + latency: 200, + startTime: Date.now(), + endTime: Date.now() + 300, + speedInMbps: 1, + networkType: 'Wi-Fi', + tlsCipherSuite: 'none', + tlsVersion: 'none', + isCached: false, + httpVersion: 'h2', + compressedSize: 10 * 1024, + size: 6 * 1024 * 1024, + connectionTime: 0, + }, }, }; - client.sendTelemetryEvent('testGroup', group, 1000); + client.sendTelemetryEvent('Cold Start', group, 1000); expect(require('@utils/log').logInfo).toHaveBeenCalled(); }); @@ -173,7 +209,7 @@ describe('ClientTraking', () => { const options = { method: 'GET', - groupLabel: 'testGroup', + groupLabel: 'Cold Start' as RequestGroupLabel, }; const result = await client.doFetchWithTracking('https://example.com/api', options); @@ -185,12 +221,98 @@ describe('ClientTraking', () => { const options = { method: 'GET', - groupLabel: 'testGroup', + groupLabel: 'Cold Start' as RequestGroupLabel, }; await expect(client.doFetchWithTracking('https://example.com/api', options)).rejects.toThrow('Received invalid response from the server.'); }); + it('should handle non-ok response with error details', async () => { + apiClientMock.get.mockResolvedValue({ + ok: false, + data: { + message: 'Custom error message', + id: 'error_id_123', + }, + code: 400, + }); + + const options = { + method: 'GET', + groupLabel: 'Cold Start' as RequestGroupLabel, + }; + + try { + await client.doFetchWithTracking('https://example.com/api', options); + fail('Expected error to be thrown'); + } catch (error: unknown) { + const clientError = error as ClientError; + + expect((clientError.details as {message: string}).message).toBe('Custom error message'); + expect((clientError.details as {server_error_id: string}).server_error_id).toBe('error_id_123'); + expect((clientError.details as {status_code: string}).status_code).toBe(400); + } + }); + + it('should handle non-ok response without error details', async () => { + apiClientMock.get.mockResolvedValue({ + ok: false, + data: {}, + code: 500, + }); + + const options = { + method: 'GET', + groupLabel: 'Cold Start' as RequestGroupLabel, + }; + + try { + await client.doFetchWithTracking('https://example.com/api', options); + fail('Expected error to be thrown'); + } catch (error: unknown) { + const clientError = error as ClientError; + + expect((clientError.details as {message: string}).message).toBe('Response with status code 500'); + expect((clientError.details as {status_code: string}).status_code).toBe(500); + } + }); + + it('should handle response with bearer token header', async () => { + apiClientMock.get.mockResolvedValue({ + ok: true, + data: {success: true}, + headers: { + Token: 'new_bearer_token', + }, + }); + + const options = { + method: 'GET', + groupLabel: 'Cold Start' as RequestGroupLabel, + }; + + await client.doFetchWithTracking('https://example.com/api', options); + expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} new_bearer_token`); + }); + + it('should handle response with lowercase bearer token header', async () => { + apiClientMock.get.mockResolvedValue({ + ok: true, + data: {success: true}, + headers: { + token: 'new_lowercase_token', + }, + }); + + const options = { + method: 'GET', + groupLabel: 'Cold Start' as RequestGroupLabel, + }; + + await client.doFetchWithTracking('https://example.com/api', options); + expect(client.requestHeaders[ClientConstants.HEADER_AUTH]).toBe(`${ClientConstants.HEADER_BEARER} new_lowercase_token`); + }); + it('should call increment and decrement the same number of times as doFetchWithTracking, and handleRequestCompletion only once', async () => { const incrementSpy = jest.spyOn(client, 'incrementRequestCount'); const decrementSpy = jest.spyOn(client, 'decrementRequestCount'); @@ -203,67 +325,501 @@ describe('ClientTraking', () => { ok: true, data: {success: true}, headers: {}, - metrics: {latency: 200, size: 500, compressedSize: 300}, + metrics: {latency: 200, size: 500, compressedSize: 300, startTime: Date.now(), endTime: Date.now() + 100, speedInMbps: 1}, }); }, 200); // Simulate a 100ms network delay }); }); - client.initTrackGroup('testGroup'); + client.initTrackGroup('Cold Start'); // Simulate multiple fetch calls const promises = [ - client.doFetchWithTracking('https://example.com/api1', {method: 'GET', groupLabel: 'testGroup'}), - client.doFetchWithTracking('https://example.com/api2', {method: 'GET', groupLabel: 'testGroup'}), - client.doFetchWithTracking('https://example.com/api3', {method: 'GET', groupLabel: 'testGroup'}), + client.doFetchWithTracking('https://example.com/api1', {method: 'GET', groupLabel: 'Cold Start'}), + client.doFetchWithTracking('https://example.com/api2', {method: 'GET', groupLabel: 'Cold Start'}), + client.doFetchWithTracking('https://example.com/api3', {method: 'GET', groupLabel: 'Cold Start'}), ]; // Wait for all fetches to resolve await Promise.all(promises); - const group = client.requestGroups.get('testGroup')!; + const group = client.requestGroups.get('Cold Start')!; expect(group.activeCount).toBe(0); - await test_helper.wait(100); + await test_helper.wait(300); expect(handleRequestCompletionSpy).toHaveBeenCalledTimes(1); expect(incrementSpy).toHaveBeenCalledTimes(3); expect(decrementSpy).toHaveBeenCalledTimes(3); }); + it('should handle invalid HTTP method', async () => { + const options = { + method: 'INVALID' as string, + groupLabel: 'Cold Start' as RequestGroupLabel, + }; + + const result = await client.doFetchWithTracking('https://example.com/api', options) as unknown as {error: string}; + expect(result.error).toEqual(new Error('Invalid request method')); + }); + + it('should handle server version changes without cache control', async () => { + apiClientMock.get.mockResolvedValue({ + ok: true, + data: {success: true}, + headers: { + 'x-version-id': '5.30.0', + }, + }); + + await client.doFetchWithTracking('https://example.com/api', { + method: 'GET', + groupLabel: 'Cold Start', + }); + + expect(client.serverVersion).toBe('5.30.0'); + expect(DeviceEventEmitter.emit).toHaveBeenCalledWith( + Events.SERVER_VERSION_CHANGED, + {serverUrl: 'https://example.com', serverVersion: '5.30.0'}, + ); + }); + + it('should not update server version when cache control present', async () => { + client.serverVersion = '5.20.0'; + + apiClientMock.get.mockResolvedValue({ + ok: true, + data: {success: true}, + headers: { + 'x-version-id': '5.31.0', + 'Cache-Control': 'no-cache', + }, + }); + + await client.doFetchWithTracking('https://example.com/api', { + method: 'GET', + groupLabel: 'Cold Start', + }); + + expect(client.serverVersion).toBe('5.20.0'); + }); + it('should track duplicate requests correctly and log duplicate details', async () => { const logDebugSpy = jest.spyOn(require('@utils/log'), 'logDebug'); apiClientMock.get.mockResolvedValue({ ok: true, data: {success: true}, headers: {}, - metrics: {latency: 150, size: 200, compressedSize: 100}, + metrics: {latency: 150, size: 200, compressedSize: 100, startTime: Date.now(), endTime: Date.now() + 100, speedInMbps: 1}, }); - client.initTrackGroup('testGroup'); + client.initTrackGroup('Cold Start'); // Simulate multiple fetch calls to the same URL const promises = [ - client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'testGroup'}), - client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'testGroup'}), - client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'testGroup'}), + client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'Cold Start'}), + client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'Cold Start'}), + client.doFetchWithTracking('https://example.com/api', {method: 'GET', groupLabel: 'Cold Start'}), ]; // Wait for all fetches to resolve await Promise.all(promises); // Verify that the group tracked duplicates correctly - const group = client.requestGroups.get('testGroup')!; + const group = client.requestGroups.get('Cold Start')!; expect(group.urls['https://example.com/api'].count).toBe(3); // URL was requested 3 times expect(group.totalSize).toBe(600); // Total size = 3 * 200 expect(group.totalCompressedSize).toBe(300); // Total compressed size = 3 * 100 - await test_helper.wait(100); + await test_helper.wait(300); // Verify duplicate logging expect(logDebugSpy).toHaveBeenCalledWith( - 'Duplicate URLs:\n', - expect.stringContaining('https://example.com/api'), + expect.stringContaining('Duplicate URLs:\n1 - https://example.com/api'), ); }); + + it('should return true when checking allRequestsCompleted for non-existent group', () => { + // Try to check completion status for a non-existent group + const result = client.allRequestsCompleted('Non Existent Group' as RequestGroupLabel); + + // Should return true since there are no pending requests + expect(result).toBe(true); + }); + + describe('Request Categorization and Speed Calculations', () => { + it('should create multiple parallel groups for non-overlapping requests', () => { + client.initTrackGroup('Cold Start'); + const group = client.requestGroups.get('Cold Start')!; + const baseTime = Date.now(); + + // First parallel group (2 concurrent requests) + group.urls = { + 'https://example.com/api1': { + count: 1, + metrics: { + latency: 100, + startTime: baseTime, + endTime: baseTime + 200, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + 'https://example.com/api2': { + count: 1, + metrics: { + latency: 150, + startTime: baseTime + 50, + endTime: baseTime + 250, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + + // Second parallel group (3 concurrent requests) + 'https://example.com/api3': { + count: 1, + metrics: { + latency: 120, + startTime: baseTime + 300, + endTime: baseTime + 500, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + 'https://example.com/api4': { + count: 1, + metrics: { + latency: 180, + startTime: baseTime + 320, + endTime: baseTime + 520, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + 'https://example.com/api5': { + count: 1, + metrics: { + latency: 160, + startTime: baseTime + 340, + endTime: baseTime + 540, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + }; + + const result = client.categorizeRequests('Cold Start'); + + // Verify parallel groups + expect(result.parallelGroups).toHaveLength(2); + expect(result.maxConcurrency).toBe(3); + + // First group should have 2 requests + expect(result.parallelGroups[0].requests).toHaveLength(2); + expect(result.parallelGroups[0].latency).toBe(150); // Max latency of first group + + // Second group should have 3 requests + expect(result.parallelGroups[1].requests).toHaveLength(3); + expect(result.parallelGroups[1].latency).toBe(180); // Max latency of second group + }); + + it('should handle overlapping parallel groups correctly', () => { + client.initTrackGroup('Cold Start'); + const group = client.requestGroups.get('Cold Start')!; + const baseTime = Date.now(); + + group.urls = { + + // First request starts early and ends late + 'https://example.com/api1': { + count: 1, + metrics: { + latency: 500, + startTime: baseTime, + endTime: baseTime + 1000, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + + // These requests start during the first request + 'https://example.com/api2': { + count: 1, + metrics: { + latency: 200, + startTime: baseTime + 100, + endTime: baseTime + 300, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + 'https://example.com/api3': { + count: 1, + metrics: { + latency: 200, + startTime: baseTime + 200, + endTime: baseTime + 400, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + }; + + const result = client.categorizeRequests('Cold Start'); + + // Should create a single parallel group since all requests overlap + expect(result.parallelGroups).toHaveLength(1); + expect(result.maxConcurrency).toBe(3); + + // The group should contain all 3 requests + expect(result.parallelGroups[0].requests).toHaveLength(3); + expect(result.parallelGroups[0].latency).toBe(500); // Should take the max latency + }); + + it('should handle requests with latency extending beyond group end time', () => { + client.initTrackGroup('Cold Start'); + const group = client.requestGroups.get('Cold Start')!; + const baseTime = Date.now(); + const dataTransferTime = 200; + + group.urls = { + + // First request starts and ends quickly + 'https://example.com/api1': { + count: 1, + metrics: { + latency: 100, + startTime: baseTime, + endTime: baseTime + dataTransferTime + 100, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + + // Second request starts at same time but has much longer latency + 'https://example.com/api2': { + count: 1, + metrics: { + latency: 500, // Long latency + startTime: baseTime, + endTime: baseTime + dataTransferTime + 500, // Ends much later + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + }; + + const result = client.categorizeRequests('Cold Start'); + + // Should create a single parallel group + expect(result.parallelGroups).toHaveLength(1); + expect(result.maxConcurrency).toBe(2); + + const group1 = result.parallelGroups[0]; + + // Group end time should be the earliest end time + expect(group1.endTime).toBe(baseTime + dataTransferTime + 100); + + // But latency should be from the longest request + expect(group1.latency).toBe(500); + }); + + it('should handle requests with latency extending beyond group end time and separate parallel groups', () => { + client.initTrackGroup('Cold Start'); + const group = client.requestGroups.get('Cold Start')!; + const baseTime = Date.now(); + const dataTransferTime = 200; + const firstGroupMinEndTime = baseTime + dataTransferTime + 100; + + group.urls = { + + // First parallel group + 'https://example.com/api1': { + count: 1, + metrics: { + latency: 100, + startTime: baseTime, + endTime: firstGroupMinEndTime, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + 'https://example.com/api2': { + count: 1, + metrics: { + latency: 500, + startTime: baseTime, + endTime: baseTime + dataTransferTime + 500, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + + // Second parallel group - starts after first group has ended + 'https://example.com/api3': { + count: 1, + metrics: { + latency: 300, + startTime: firstGroupMinEndTime + 1, // Starts after first group + endTime: firstGroupMinEndTime + dataTransferTime + 300, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + 'https://example.com/api4': { + count: 1, + metrics: { + latency: 600, + startTime: firstGroupMinEndTime + 1, // Starts with api3 + endTime: firstGroupMinEndTime + 100 + 600, + size: 1000, + compressedSize: 500, + } as ClientResponseMetrics, + }, + }; + + const result = client.categorizeRequests('Cold Start'); + + // Should create two parallel groups + expect(result.parallelGroups).toHaveLength(2); + expect(result.maxConcurrency).toBe(2); + + const group1 = result.parallelGroups[0]; + const group2 = result.parallelGroups[1]; + + // First group assertions + expect(group1.endTime).toBe(baseTime + dataTransferTime + 100); + + // longest latency on the first group is 500, but the first group ends after 300ms, so the effective latency is only 300ms + expect(group1.latency).toBe(300); + expect(group1.requests).toHaveLength(2); + + // Second group assertions + expect(group2.endTime).toBe(firstGroupMinEndTime + dataTransferTime + 300); + + // second group min endTime after 500ms, but it's the last group to end, so the effective latency for the group + // is 600ms (longest latency) + expect(group2.latency).toBe(600); + expect(group2.requests).toHaveLength(2); + }); + + it('should calculate average speed and effective latency for multiple parallel groups', () => { + const baseTime = Date.now(); + + // Create test parallel groups with known data sizes and timings + const parallelGroups: ParallelGroup[] = [ + { + startTime: baseTime, + endTime: baseTime + 1000, + latency: 200, + requests: [ + { + latency: 200, + startTime: baseTime, + endTime: baseTime + 1000, + compressedSize: 1000000, // 1MB + } as ClientResponseMetrics, + { + latency: 150, + startTime: baseTime + 100, + endTime: baseTime + 900, + compressedSize: 500000, // 0.5MB + } as ClientResponseMetrics, + ], + }, + { + startTime: baseTime + 1100, + endTime: baseTime + 2000, + latency: 300, + requests: [ + { + latency: 300, + startTime: baseTime + 1100, + endTime: baseTime + 2000, + compressedSize: 2000000, // 2MB + } as ClientResponseMetrics, + { + latency: 250, + startTime: baseTime + 1200, + endTime: baseTime + 1900, + compressedSize: 1500000, // 1.5MB + } as ClientResponseMetrics, + ], + }, + ]; + + // Total compressed size: 5MB (5,000,000 bytes) + // Total elapsed time: 2 seconds + // Total effective latency: 500ms (200ms + 300ms) + const result = client.calculateAverageSpeedWithCategories(parallelGroups, 2); + + // Expected average speed: + // Total bits = 5,000,000 * 8 = 40,000,000 bits + // Data transfer time = 2 seconds - (500ms / 1000) = 1.5 seconds + // Speed = 40,000,000 / 1.5 = 26,666,666.67 bps = 26.67 Mbps + expect(result.averageSpeedMbps).toBeCloseTo(26.67, 1); + expect(result.effectiveLatency).toBe(500); // Sum of max latencies from each group + }); + + it('should handle zero transfer time in speed calculation', () => { + client.initTrackGroup('Cold Start'); + const group = client.requestGroups.get('Cold Start')!; + const now = Date.now(); + + // Set up metrics where transfer time would be zero + group.urls = { + 'https://example.com/api': { + count: 1, + metrics: { + latency: 1000, + startTime: now, + endTime: now + 1000, + speedInMbps: 0, + networkType: 'Wi-Fi', + tlsCipherSuite: 'none', + tlsVersion: 'none', + isCached: false, + httpVersion: 'h2', + size: 1000, + compressedSize: 500, + connectionTime: 0, + } as ClientResponseMetrics, + }, + }; + + const parallelGroups: ParallelGroup[] = [{ + startTime: now, + endTime: now + 1000, + latency: 1000, + requests: [group.urls['https://example.com/api'].metrics!], + }]; + + const result = client.calculateAverageSpeedWithCategories( + parallelGroups, + 1, // 1 second elapsed + ); + + expect(result.averageSpeedMbps).toBe(0); // Should be 0 since data transfer time is 0/negative + expect(result.effectiveLatency).toBe(1000); + }); + + it('should return empty result when groupData is not found', () => { + // Try to categorize requests for a non-existent group + const result = client.categorizeRequests('Non Existent Group' as RequestGroupLabel); + + // Verify empty result structure + expect(result.parallelGroups).toEqual([]); + expect(result.maxConcurrency).toBe(0); + }); + + it('should return default latency when groupData is not found', () => { + // Try to get average latency for a non-existent group + const result = client.getAverageLatency('Non Existent Group' as RequestGroupLabel); + + // Should return default latency of 100ms + expect(result).toBe(100); + }); + }); }); +/* eslint-enable max-lines */ diff --git a/app/client/rest/tracking.ts b/app/client/rest/tracking.ts index cedbfe32e..ec19ee799 100644 --- a/app/client/rest/tracking.ts +++ b/app/client/rest/tracking.ts @@ -3,9 +3,12 @@ import {DeviceEventEmitter, Platform} from 'react-native'; +import {CollectNetworkMetrics} from '@assets/config.json'; import {Events} from '@constants'; import {t} from '@i18n'; import {setServerCredentials} from '@init/credentials'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; +import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant'; import {getFormattedFileSize} from '@utils/file'; import {logDebug, logInfo} from '@utils/log'; import {semverFromServerVersion} from '@utils/server'; @@ -30,6 +33,30 @@ type GroupData = { completionTimer?: NodeJS.Timeout; } +/** + * ParallelGroup + * @typedef {Object} ParallelGroup + * @property {number} startTime is the start time of all requests in the group + * @property {number} endTime is min end time of all requests in the group + * @property {number} latency is the max latency (in ms) + * @property {ClientResponseMetrics[]} requests is the list of requests in the group + */ +type ParallelGroup = { + startTime: number; + endTime: number; + latency: number; + requests: ClientResponseMetrics[]; +} + +type CategorizedRequestsResult = { + parallelGroups: ParallelGroup[]; + maxConcurrency: number; +}; + +export const testExports = { + ParallelGroup: {} as ParallelGroup, +}; + export default class ClientTracking { apiClient: APIClientInterface; csrfToken = ''; @@ -63,7 +90,7 @@ export default class ClientTracking { return headers; } - initTrackGroup(groupLabel: string) { + initTrackGroup(groupLabel: RequestGroupLabel) { if (!this.requestGroups.has(groupLabel)) { this.requestGroups.set(groupLabel, { activeCount: 0, @@ -76,7 +103,7 @@ export default class ClientTracking { } } - trackRequest(groupLabel: string, url: string, metrics?: ClientResponseMetrics) { + trackRequest(groupLabel: RequestGroupLabel, url: string, metrics?: ClientResponseMetrics) { this.initTrackGroup(groupLabel); const group = this.requestGroups.get(groupLabel)!; @@ -93,32 +120,134 @@ export default class ClientTracking { group.totalCompressedSize += metrics?.compressedSize ?? 0; } - incrementRequestCount(groupLabel: string) { + getAverageLatency(groupLabel: RequestGroupLabel): number { + const groupData = this.requestGroups.get(groupLabel); + if (!groupData) { + return 100; + } + + const urlData = Object.entries(groupData.urls); + const sumLatency = urlData.reduce((result, url) => (result + (url[1].metrics?.latency ?? 0)), 0); + return sumLatency / urlData.length; + } + + categorizeRequests(groupLabel: RequestGroupLabel): CategorizedRequestsResult { + const groupData = this.requestGroups.get(groupLabel); + if (!groupData) { + const emptyResult: CategorizedRequestsResult = { + parallelGroups: [], + maxConcurrency: 0, + }; + return emptyResult; + } + + const requestsMetrics = Object.entries(groupData.urls). + map((e) => e[1].metrics). + filter((m) => m != null); + requestsMetrics.sort((a, b) => a.startTime - b.startTime); + + const parallelGroups: ParallelGroup[] = []; + let maxConcurrency = 0; + + // First pass: group requests + for (const metrics of requestsMetrics) { + const groupIndex = parallelGroups.findIndex((g) => metrics.startTime <= g.endTime); + + if (groupIndex >= 0) { + const currentGroup = parallelGroups[groupIndex]; + currentGroup.requests.push(metrics); + currentGroup.endTime = Math.min(currentGroup.endTime, metrics.endTime); + currentGroup.latency = Math.max(currentGroup.latency, metrics.latency); + maxConcurrency = Math.max(maxConcurrency, currentGroup.requests.length); + } else { + // Create a new parallel group + parallelGroups.push({ + startTime: metrics.startTime, + endTime: metrics.endTime, + latency: metrics.latency, + requests: [metrics], + }); + } + } + + // Second pass: recalculate max latency for all groups (except the last) + // The max latency should only be considered up until the startTime of the next group + for (let i = 0; i < parallelGroups.length - 1; i++) { + const currentGroup = parallelGroups[i]; + let maxGroupLatency = 0; + + for (const metrics of currentGroup.requests) { + let currentLatency = metrics.latency; + + if ((metrics.startTime + metrics.latency) > currentGroup.endTime) { + currentLatency = currentGroup.endTime - metrics.startTime; + } + maxGroupLatency = Math.max(maxGroupLatency, currentLatency); + } + + currentGroup.latency = maxGroupLatency; + } + + return {parallelGroups, maxConcurrency}; + } + + calculateAverageSpeedWithCategories = ( + parallelGroups: ParallelGroup[], + elapsedTimeInSeconds: number, // Observed total elapsed time in seconds + ): { averageSpeedMbps: number; effectiveLatency: number } => { + // Step 1: Calculate total data size in bits + const totalDataBits = parallelGroups.reduce((sum, group) => { + return sum + group.requests.reduce((groupSum, req) => groupSum + (req.compressedSize * 8), 0); + }, 0); + + // Step 2: Calculate effective latency (in ms) + const effectiveLatency = parallelGroups.reduce((sum, group) => sum + group.latency, 0); + + // Step 3: Calculate data transfer time + const dataTransferTime = elapsedTimeInSeconds - (effectiveLatency / 1000); + + // Handle edge case: if data transfer time is zero or negative. + if (dataTransferTime <= 0) { + return {averageSpeedMbps: 0, effectiveLatency}; + } + + // Step 4: Calculate average speed + const averageSpeedBps = totalDataBits / dataTransferTime; // Speed in bps + const averageSpeedMbps = averageSpeedBps / 1_000_000; // Convert to Mbps + + return { + averageSpeedMbps, + effectiveLatency, + }; + }; + + incrementRequestCount(groupLabel: RequestGroupLabel) { this.initTrackGroup(groupLabel); const group = this.requestGroups.get(groupLabel)!; group.activeCount += 1; } - decrementRequestCount(groupLabel: string) { + decrementRequestCount(groupLabel: RequestGroupLabel) { const group = this.requestGroups.get(groupLabel); if (group) { group.activeCount -= 1; if (group.activeCount <= 0 && !group.completionFlag) { this.clearCompletionTimer(groupLabel); + const latency = this.getAverageLatency(groupLabel); group.completionTimer = setTimeout(() => { if (this.allRequestsCompleted(groupLabel)) { group.completionFlag = true; this.handleRequestCompletion(groupLabel); this.clearCompletionTimer(groupLabel); } - }, 100); // Adjust delay as needed (e.g., 100ms) should we set this based on latency or something? + }, latency); } } } - clearCompletionTimer(groupLabel: string) { + clearCompletionTimer(groupLabel: RequestGroupLabel) { const group = this.requestGroups.get(groupLabel); if (group?.completionTimer) { clearTimeout(group.completionTimer); @@ -126,7 +255,7 @@ export default class ClientTracking { } } - handleRequestCompletion(groupLabel: string) { + handleRequestCompletion(groupLabel: RequestGroupLabel) { const group = this.requestGroups.get(groupLabel); if (group) { const duration = Date.now() - group.startTime; @@ -138,31 +267,62 @@ export default class ClientTracking { } } - allRequestsCompleted(groupLabel: string): boolean { + allRequestsCompleted(groupLabel: RequestGroupLabel): boolean { const group = this.requestGroups.get(groupLabel); return group ? group.activeCount <= 0 : true; } - sendTelemetryEvent(groupLabel: string, groupData: GroupData, duration: number) { - const urls = Object.keys(groupData.urls); - const urlData = Object.entries(groupData.urls); + sendTelemetryEvent(groupLabel: RequestGroupLabel, groupData: GroupData, duration: number) { + const {totalCompressedSize, totalSize, urls: groupedUrls} = groupData; + const urls = Object.keys(groupedUrls); + const urlData = Object.entries(groupedUrls); const dupe = urlData.filter((u) => u[1].count > 1); - const urlCount = urlData.reduce((result, url) => (result + url[1].count), 0); - const sumLatency = urlData.reduce((result, url) => (result + (url[1].metrics?.latency ?? 0)), 0); - const latency = sumLatency / urlCount; - logInfo(`Telemetry event on ${Platform.OS} for server ${this.apiClient.baseUrl} - Group "${groupLabel}" - requesting ${urls.length} urls - total Compressed size of: ${getFormattedFileSize(groupData.totalCompressedSize)} - total size of: ${getFormattedFileSize(groupData.totalSize)} - elapsed time: ${duration / 1000} seconds - average latency: ${latency} ms`); + const latency = this.getAverageLatency(groupLabel); + const {parallelGroups, maxConcurrency} = this.categorizeRequests(groupLabel); + const elapsedTimeInSeconds = duration / 1000; + const {averageSpeedMbps, effectiveLatency} = this.calculateAverageSpeedWithCategories(parallelGroups, elapsedTimeInSeconds); + + logInfo(`Telemetry Event on ${Platform.OS} for Server ${this.apiClient.baseUrl} + Group: "${groupLabel}" + Total Requests: ${urls.length} URLs + Max Concurrency: ${maxConcurrency} + Parallel Groups: ${parallelGroups.length} + Requests in Each Group: ${parallelGroups.map((g) => g.requests.length).join(', ')} + Total Compressed Size: ${getFormattedFileSize(groupData.totalCompressedSize)} + Total Size: ${getFormattedFileSize(groupData.totalSize)} + Elapsed Time: ${elapsedTimeInSeconds} seconds (${duration} ms) + Average Request Latency: ${latency} ms + Effective Latency: ${effectiveLatency} ms + Average Speed: ${averageSpeedMbps.toFixed(4)} Mbps + `, + ); if (dupe.length) { - logDebug('Duplicate URLs:\n', dupe.map((d) => `${d[0]} ${JSON.stringify(d[1])}`).join('\n')); + logDebug(`Duplicate URLs:\n${dupe.map((d, i) => `${i + 1} - ${d[0]} ${JSON.stringify(d[1])}`).join('\n')}`); } - // Integrate with telemetry framework here + const {collectNetworkRequestData} = PerformanceMetricsManager; + + const commonArguments = {serverUrl: this.apiClient.baseUrl, groupLabel}; + const metricsData: Array<[NetworkRequestMetrics, number]> = [ + [NetworkRequestMetrics.AverageSpeed, averageSpeedMbps], + [NetworkRequestMetrics.EffectiveLatency, effectiveLatency], + [NetworkRequestMetrics.ElapsedTime, elapsedTimeInSeconds], + [NetworkRequestMetrics.Latency, latency], + [NetworkRequestMetrics.TotalCompressedSize, totalCompressedSize], + [NetworkRequestMetrics.TotalRequests, urls.length], + [NetworkRequestMetrics.TotalSequentialRequests, parallelGroups.length], + [NetworkRequestMetrics.TotalSize, totalSize], + ]; + + metricsData.forEach(([metric, value]) => { + collectNetworkRequestData(metric, value, commonArguments); + }); + + // Send metrics for each parallel group + parallelGroups.forEach((group) => { + collectNetworkRequestData(NetworkRequestMetrics.TotalParallelRequests, group.requests.length, commonArguments); + }); } buildRequestOptions(options: ClientOptions): RequestOptions { @@ -208,14 +368,14 @@ export default class ClientTracking { })}; } - if (groupLabel) { + if (groupLabel && CollectNetworkMetrics) { this.incrementRequestCount(groupLabel); } try { const response = await request!(url, this.buildRequestOptions(options)); const headers: ClientHeaders = response.headers || {}; - if (groupLabel) { + if (groupLabel && CollectNetworkMetrics) { this.trackRequest(groupLabel, url, response.metrics); } const serverVersion = semverFromServerVersion( @@ -255,7 +415,7 @@ export default class ClientTracking { details: error, }); } finally { - if (groupLabel) { + if (groupLabel && CollectNetworkMetrics) { this.decrementRequestCount(groupLabel); } } diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index 840f045a5..cb2aea9e1 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -10,7 +10,7 @@ import type ClientBase from './base'; export interface ClientUsersMix { createUser: (user: UserProfile, token: string, inviteId: string) => Promise; - patchMe: (userPatch: Partial, groupLabel?: string) => Promise; + patchMe: (userPatch: Partial, groupLabel?: RequestGroupLabel) => Promise; patchUser: (userPatch: Partial & {id: string}) => Promise; updateUser: (user: UserProfile) => Promise; demoteUserToGuest: (userId: string) => Promise; @@ -21,15 +21,15 @@ export interface ClientUsersMix { loginById: (id: string, password: string, token?: string, deviceId?: string) => Promise; logout: () => Promise; getProfiles: (page?: number, perPage?: number, options?: Record) => Promise; - getProfilesByIds: (userIds: string[], options?: Record, groupLabel?: string) => Promise; - getProfilesByUsernames: (usernames: string[], groupLabel?: string) => Promise; + getProfilesByIds: (userIds: string[], options?: Record, groupLabel?: RequestGroupLabel) => Promise; + getProfilesByUsernames: (usernames: string[], groupLabel?: RequestGroupLabel) => Promise; getProfilesInTeam: (teamId: string, page?: number, perPage?: number, sort?: string, options?: Record) => Promise; getProfilesNotInTeam: (teamId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise; getProfilesWithoutTeam: (page?: number, perPage?: number, options?: Record) => Promise; - getProfilesInChannel: (channelId: string, options?: GetUsersOptions, groupLabel?: string) => Promise; - getProfilesInGroupChannels: (channelsIds: string[], groupLabel?: string) => Promise<{[x: string]: UserProfile[]}>; + getProfilesInChannel: (channelId: string, options?: GetUsersOptions, groupLabel?: RequestGroupLabel) => Promise; + getProfilesInGroupChannels: (channelsIds: string[], groupLabel?: RequestGroupLabel) => Promise<{[x: string]: UserProfile[]}>; getProfilesNotInChannel: (teamId: string, channelId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise; - getMe: (groupLabel?: string) => Promise; + getMe: (groupLabel?: RequestGroupLabel) => Promise; getUser: (userId: string) => Promise; getUserByUsername: (username: string) => Promise; getUserByEmail: (email: string) => Promise; @@ -38,10 +38,10 @@ export interface ClientUsersMix { autocompleteUsers: (name: string, teamId: string, channelId?: string, options?: Record) => Promise<{users: UserProfile[]; out_of_channel?: UserProfile[]}>; getSessions: (userId: string) => Promise; checkUserMfa: (loginId: string) => Promise<{mfa_required: boolean}>; - setExtraSessionProps: (deviceId: string, notificationsEnabled: boolean, version: string | null, groupLabel?: string) => Promise<{}>; + setExtraSessionProps: (deviceId: string, notificationsEnabled: boolean, version: string | null, groupLabel?: RequestGroupLabel) => Promise<{}>; searchUsers: (term: string, options: SearchUserOptions) => Promise; getStatusesByIds: (userIds: string[]) => Promise; - getStatus: (userId: string, groupLabel?: string) => Promise; + getStatus: (userId: string, groupLabel?: RequestGroupLabel) => Promise; updateStatus: (status: UserStatus) => Promise; updateCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>; unsetCustomStatus: () => Promise<{status: string}>; @@ -66,7 +66,7 @@ const ClientUsers = >(superclass: TBase) = ); }; - patchMe = async (userPatch: Partial, groupLabel?: string) => { + patchMe = async (userPatch: Partial, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUserRoute('me')}/patch`, {method: 'put', body: userPatch, groupLabel}, @@ -177,14 +177,14 @@ const ClientUsers = >(superclass: TBase) = ); }; - getProfilesByIds = async (userIds: string[], options = {}, groupLabel?: string) => { + getProfilesByIds = async (userIds: string[], options = {}, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUsersRoute()}/ids${buildQueryString(options)}`, {method: 'post', body: userIds, groupLabel}, ); }; - getProfilesByUsernames = async (usernames: string[], groupLabel?: string) => { + getProfilesByUsernames = async (usernames: string[], groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUsersRoute()}/usernames`, {method: 'post', body: usernames, groupLabel}, @@ -217,7 +217,7 @@ const ClientUsers = >(superclass: TBase) = ); }; - getProfilesInChannel = async (channelId: string, options: GetUsersOptions, groupLabel?: string) => { + getProfilesInChannel = async (channelId: string, options: GetUsersOptions, groupLabel?: RequestGroupLabel) => { const queryStringObj = {in_channel: channelId, ...options}; return this.doFetch( `${this.getUsersRoute()}${buildQueryString(queryStringObj)}`, @@ -225,7 +225,7 @@ const ClientUsers = >(superclass: TBase) = ); }; - getProfilesInGroupChannels = async (channelsIds: string[], groupLabel?: string) => { + getProfilesInGroupChannels = async (channelsIds: string[], groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUsersRoute()}/group_channels`, {method: 'post', body: channelsIds, groupLabel}, @@ -244,7 +244,7 @@ const ClientUsers = >(superclass: TBase) = ); }; - getMe = async (groupLabel?: string) => { + getMe = async (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUserRoute('me')}`, {method: 'get', groupLabel}, @@ -332,7 +332,7 @@ const ClientUsers = >(superclass: TBase) = ); }; - setExtraSessionProps = async (deviceId: string, deviceNotificationDisabled: boolean, version: string | null, groupLabel?: string) => { + setExtraSessionProps = async (deviceId: string, deviceNotificationDisabled: boolean, version: string | null, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUsersRoute()}/sessions/device`, { @@ -361,7 +361,7 @@ const ClientUsers = >(superclass: TBase) = ); }; - getStatus = async (userId: string, groupLabel?: string) => { + getStatus = async (userId: string, groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getUserRoute(userId)}/status`, {method: 'get', groupLabel}, diff --git a/app/constants/general.ts b/app/constants/general.ts index c94ce5ceb..38f74f886 100644 --- a/app/constants/general.ts +++ b/app/constants/general.ts @@ -8,6 +8,7 @@ export default { POST_CHUNK_SIZE: 60, POST_AROUND_CHUNK_SIZE: 10, CHANNELS_CHUNK_SIZE: 50, + CHANNEL_MEMBERS_CHUNK_SIZE: 200, CRT_CHUNK_SIZE: 60, STATUS_INTERVAL: 60000, AUTOCOMPLETE_LIMIT_DEFAULT: 25, diff --git a/app/init/launch.ts b/app/init/launch.ts index ddaf08012..7b36d49ec 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -182,7 +182,7 @@ const launchToHome = async (props: LaunchProps) => { openPushNotification = Boolean(props.serverUrl && !props.launchError && extra.userInteraction && extra.payload?.channel_id && !extra.payload?.userInfo?.local); if (openPushNotification) { await resetToHome(props); - return pushNotificationEntry(props.serverUrl!, extra.payload!, 'notification'); + return pushNotificationEntry(props.serverUrl!, extra.payload!, 'Notification'); } appEntry(props.serverUrl!); diff --git a/app/managers/apps_manager.ts b/app/managers/apps_manager.ts index d98462e88..83b842277 100644 --- a/app/managers/apps_manager.ts +++ b/app/managers/apps_manager.ts @@ -103,7 +103,7 @@ class AppsManager { } }; - fetchBindings = async (serverUrl: string, channelId: string, forThread = false, groupLabel?: string) => { + fetchBindings = async (serverUrl: string, channelId: string, forThread = false, groupLabel?: RequestGroupLabel) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const userId = await getCurrentUserId(database); @@ -135,13 +135,14 @@ class AppsManager { } }; - refreshAppBindings = async (serverUrl: string, groupLabel?: string) => { + refreshAppBindings = async (serverUrl: string, groupLabel?: RequestGroupLabel) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - const appsEnabled = (await getConfig(database))?.FeatureFlagAppsEnabled === 'true'; + const appsEnabled = await this.isAppsEnabled(serverUrl); if (!appsEnabled) { this.getEnabledSubject(serverUrl).next(false); this.clearServer(serverUrl); + return; } const channelId = await getCurrentChannelId(database); diff --git a/app/managers/performance_metrics_manager/constant.ts b/app/managers/performance_metrics_manager/constant.ts new file mode 100644 index 000000000..95a4692e6 --- /dev/null +++ b/app/managers/performance_metrics_manager/constant.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +export enum NetworkRequestMetrics { + AverageSpeed = 'mobile_network_requests_average_speed', + EffectiveLatency = 'mobile_network_requests_effective_latency', + ElapsedTime = 'mobile_network_requests_elapsed_time', + Latency = 'mobile_network_requests_latency', + TotalCompressedSize = 'mobile_network_requests_total_compressed_size', + TotalParallelRequests = 'mobile_network_requests_total_parallel_requests', + TotalRequests = 'mobile_network_requests_total_requests', + TotalSequentialRequests = 'mobile_network_requests_total_sequential_requests', + TotalSize = 'mobile_network_requests_total_size', +} diff --git a/app/managers/performance_metrics_manager/index.test.ts b/app/managers/performance_metrics_manager/index.test.ts index 4dbe16dc5..db2b067cd 100644 --- a/app/managers/performance_metrics_manager/index.test.ts +++ b/app/managers/performance_metrics_manager/index.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import RNUtils from '@mattermost/rnutils'; +import {AppState} from 'react-native'; import performance from 'react-native-performance'; import {mockApiClient} from '@test/mock_api_client'; @@ -10,6 +11,7 @@ import {logWarning} from '@utils/log'; import NetworkManager from '../network_manager'; +import {NetworkRequestMetrics} from './constant'; import {testExports as batcherTestExports} from './performance_metrics_batcher'; import {getBaseReportRequest} from './test_utils'; @@ -33,263 +35,348 @@ jest.mock('@utils/log', () => ({ performance.timeOrigin = TEST_EPOCH; -describe('load metrics', () => { +describe('performance_metrics_manager', () => { const serverUrl = 'http://www.someserverurl.com/'; const expectedUrl = `${serverUrl}/api/v4/client_perf`; let PerformanceMetricsManager = new PerformanceMetricsManagerClass(); - const getMeasure = (timestamp: number, value: number): PerformanceReportMeasure => { - return { - metric: 'mobile_load', - timestamp, - value, - }; - }; - beforeEach(async () => { + jest.useFakeTimers({doNotFake: [ + 'cancelAnimationFrame', + 'cancelIdleCallback', + 'clearImmediate', + 'clearInterval', + 'clearTimeout', + 'hrtime', + 'nextTick', + 'queueMicrotask', + 'requestAnimationFrame', + 'requestIdleCallback', + 'setImmediate', + 'setInterval', + ]}).setSystemTime(new Date(TEST_EPOCH)); + NetworkManager.createClient(serverUrl); const {operator} = await TestHelper.setupServerDatabase(serverUrl); await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false}); - jest.useFakeTimers({doNotFake: [ - 'cancelAnimationFrame', - 'cancelIdleCallback', - 'clearImmediate', - 'clearInterval', - 'clearTimeout', - 'hrtime', - 'nextTick', - 'queueMicrotask', - 'requestAnimationFrame', - 'requestIdleCallback', - 'setImmediate', - 'setInterval', - ]}).setSystemTime(new Date(TEST_EPOCH)); - PerformanceMetricsManager = new PerformanceMetricsManagerClass(); - - const mockHasRegisteredLoad = {hasRegisteredLoad: false}; - jest.mocked(RNUtils.setHasRegisteredLoad).mockImplementation(() => { - mockHasRegisteredLoad.hasRegisteredLoad = true; - }); - jest.mocked(RNUtils.getHasRegisteredLoad).mockImplementation(() => mockHasRegisteredLoad); }); + afterEach(async () => { - jest.useRealTimers(); NetworkManager.invalidateClient(serverUrl); await TestHelper.tearDown(); - performance.clearMarks(); - }); - it('only load on target', async () => { - performance.mark('nativeLaunchStart'); - const measure = getMeasure(TEST_EPOCH + INTERVAL_TIME, INTERVAL_TIME); - const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1); - expectedRequest.body.histograms = [measure]; + jest.clearAllMocks(); - PerformanceMetricsManager.setLoadTarget('CHANNEL'); - PerformanceMetricsManager.finishLoad('HOME', serverUrl); - await TestHelper.tick(); - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).not.toHaveBeenCalled(); - PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl); - await TestHelper.tick(); - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); - }); - - it('only register load once', async () => { - performance.mark('nativeLaunchStart'); - const measure = getMeasure(TEST_EPOCH, 0); - const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1); - expectedRequest.body.histograms = [measure]; - - PerformanceMetricsManager.setLoadTarget('HOME'); - PerformanceMetricsManager.finishLoad('HOME', serverUrl); - await TestHelper.tick(); - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); - mockApiClient.post.mockClear(); - PerformanceMetricsManager.finishLoad('HOME', serverUrl); - await TestHelper.tick(); - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).not.toHaveBeenCalled(); - }); - - it('retry if the mark is not yet present', async () => { - const measure = getMeasure(TEST_EPOCH + (RETRY_TIME * 2), RETRY_TIME); - const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1); - expectedRequest.body.histograms = [measure]; - - PerformanceMetricsManager.setLoadTarget('CHANNEL'); - PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl); - await TestHelper.tick(); - jest.advanceTimersByTime(RETRY_TIME); - await TestHelper.tick(); - performance.mark('nativeLaunchStart'); - await TestHelper.tick(); - jest.advanceTimersByTime(RETRY_TIME); - await TestHelper.tick(); - await TestHelper.tick(); - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); - }); - - it('fail graciously if no measure is set', async () => { - PerformanceMetricsManager.setLoadTarget('CHANNEL'); - PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl); - for (let i = 0; i < MAX_RETRIES; i++) { - // eslint-disable-next-line no-await-in-loop - await TestHelper.tick(); - jest.advanceTimersByTime(MAX_RETRIES); - // eslint-disable-next-line no-await-in-loop - await TestHelper.tick(); - } - await TestHelper.tick(); - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).not.toHaveBeenCalled(); - expect(logWarning).toHaveBeenCalled(); - }); -}); - -describe('other metrics', () => { - const serverUrl1 = 'http://www.someserverurl.com/'; - const expectedUrl1 = `${serverUrl1}/api/v4/client_perf`; - - const serverUrl2 = 'http://www.otherserverurl.com/'; - const expectedUrl2 = `${serverUrl2}/api/v4/client_perf`; - - const measure1: PerformanceReportMeasure = { - metric: 'mobile_channel_switch', - timestamp: TEST_EPOCH + 100, - value: 100, - }; - - const measure2: PerformanceReportMeasure = { - metric: 'mobile_team_switch', - timestamp: TEST_EPOCH + 150 + 50, - value: 150, - }; - - const PerformanceMetricsManager = new PerformanceMetricsManagerClass(); - - beforeEach(async () => { - NetworkManager.createClient(serverUrl1); - NetworkManager.createClient(serverUrl2); - const {operator: operator1} = await TestHelper.setupServerDatabase(serverUrl1); - const {operator: operator2} = await TestHelper.setupServerDatabase(serverUrl2); - await operator1.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false}); - await operator2.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false}); - jest.useFakeTimers({doNotFake: [ - 'cancelAnimationFrame', - 'cancelIdleCallback', - 'clearImmediate', - 'clearInterval', - 'clearTimeout', - 'hrtime', - 'nextTick', - 'queueMicrotask', - 'requestAnimationFrame', - 'requestIdleCallback', - 'setImmediate', - 'setInterval', - ]}).setSystemTime(new Date(TEST_EPOCH)); - }); - afterEach(async () => { jest.useRealTimers(); - NetworkManager.invalidateClient(serverUrl1); - NetworkManager.invalidateClient(serverUrl2); - await TestHelper.tearDown(); - performance.clearMarks(); + }); + describe('load metrics', () => { + const getMeasure = (timestamp: number, value: number): PerformanceReportMeasure => { + return { + metric: 'mobile_load', + timestamp, + value, + }; + }; + + beforeEach(async () => { + PerformanceMetricsManager = new PerformanceMetricsManagerClass(); + + const mockHasRegisteredLoad = {hasRegisteredLoad: false}; + jest.mocked(RNUtils.setHasRegisteredLoad).mockImplementation(() => { + mockHasRegisteredLoad.hasRegisteredLoad = true; + }); + jest.mocked(RNUtils.getHasRegisteredLoad).mockImplementation(() => mockHasRegisteredLoad); + }); + afterEach(async () => { + performance.clearMarks(); + }); + + it('only load on target', async () => { + performance.mark('nativeLaunchStart'); + const measure = getMeasure(TEST_EPOCH + INTERVAL_TIME, INTERVAL_TIME); + const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1); + expectedRequest.body.histograms = [measure]; + + PerformanceMetricsManager.setLoadTarget('CHANNEL'); + PerformanceMetricsManager.finishLoad('HOME', serverUrl); + await TestHelper.tick(); + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl); + await TestHelper.tick(); + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); + }); + + it('only register load once', async () => { + performance.mark('nativeLaunchStart'); + const measure = getMeasure(TEST_EPOCH, 0); + const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1); + expectedRequest.body.histograms = [measure]; + + PerformanceMetricsManager.setLoadTarget('HOME'); + PerformanceMetricsManager.finishLoad('HOME', serverUrl); + await TestHelper.tick(); + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); + mockApiClient.post.mockClear(); + PerformanceMetricsManager.finishLoad('HOME', serverUrl); + await TestHelper.tick(); + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('retry if the mark is not yet present', async () => { + const measure = getMeasure(TEST_EPOCH + (RETRY_TIME * 2), RETRY_TIME); + const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1); + expectedRequest.body.histograms = [measure]; + + PerformanceMetricsManager.setLoadTarget('CHANNEL'); + PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl); + await TestHelper.tick(); + jest.advanceTimersByTime(RETRY_TIME); + await TestHelper.tick(); + performance.mark('nativeLaunchStart'); + await TestHelper.tick(); + jest.advanceTimersByTime(RETRY_TIME); + await TestHelper.tick(); + await TestHelper.tick(); + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest); + }); + + it('fail graciously if no measure is set', async () => { + PerformanceMetricsManager.setLoadTarget('CHANNEL'); + PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl); + for (let i = 0; i < MAX_RETRIES; i++) { + // eslint-disable-next-line no-await-in-loop + await TestHelper.tick(); + jest.advanceTimersByTime(MAX_RETRIES); + // eslint-disable-next-line no-await-in-loop + await TestHelper.tick(); + } + await TestHelper.tick(); + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + expect(logWarning).toHaveBeenCalled(); + }); }); - it('do not send metrics when we do not start them', async () => { - PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + describe('app state changes', () => { + const getMeasure = (timestamp: number, value: number): PerformanceReportMeasure => { + return { + metric: 'mobile_channel_switch', + timestamp, + value, + }; + }; - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).not.toHaveBeenCalled(); + beforeEach(async () => { + AppState.currentState = 'active'; + + PerformanceMetricsManager = new PerformanceMetricsManagerClass(); + }); + + it('forces send on app state change to inactive, or anything other than active', async () => { + const appStateSpy = jest.spyOn(AppState, 'addEventListener'); + + const measure = getMeasure(TEST_EPOCH, 0); + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl); + + appStateSpy.mock.calls[0][1]('inactive'); + + await TestHelper.tick(); + + const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1); + expectedRequest.body.histograms = [measure]; + expect(mockApiClient.post).toHaveBeenCalled(); + + expect(mockApiClient.post).toHaveBeenCalledWith(`${serverUrl}/api/v4/client_perf`, expectedRequest); + }); }); - it('send metric after it has been started', async () => { - const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); - expectedRequest.body.histograms = [measure1]; + describe('time to interaction', () => { + beforeEach(() => { + PerformanceMetricsManager = new PerformanceMetricsManagerClass(); + }); - PerformanceMetricsManager.startMetric('mobile_channel_switch'); - jest.advanceTimersByTime(100); + it('measures time to interaction correctly', () => { + PerformanceMetricsManager.startTimeToInteraction(); + jest.advanceTimersByTime(100); + const result = PerformanceMetricsManager.measureTimeToInteraction(); + expect(result?.duration).toBe(100); + }); - PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); - await TestHelper.tick(); - - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); + it('handles missing TTI mark gracefully', () => { + const result = PerformanceMetricsManager.measureTimeToInteraction(); + expect(result).toBeUndefined(); + }); }); - it('a second end metric does not generate a second measure', async () => { - const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); - expectedRequest.body.histograms = [measure1]; + describe('network request metrics', () => { + beforeEach(async () => { + PerformanceMetricsManager = new PerformanceMetricsManagerClass(); + }); - PerformanceMetricsManager.startMetric('mobile_channel_switch'); - jest.advanceTimersByTime(100); + afterEach(async () => { + NetworkManager.invalidateClient(serverUrl); + await TestHelper.tearDown(); + }); - PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); - await TestHelper.tick(); - jest.advanceTimersByTime(100); + it('collects network request metrics', async () => { + const timestamp = TEST_EPOCH; + jest.setSystemTime(new Date(timestamp)); - PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); - await TestHelper.tick(); + PerformanceMetricsManager.collectNetworkRequestData( + NetworkRequestMetrics.ElapsedTime, + 100, + {serverUrl, groupLabel: 'Login'}, + ); - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + + const expectedRequest = getBaseReportRequest(timestamp, timestamp + 1); + expectedRequest.body.histograms = [{ + metric: NetworkRequestMetrics.ElapsedTime, + value: 100, + timestamp, + label: {network_request_group: 'Login'}, + }]; + + expect(mockApiClient.post).toHaveBeenCalledWith( + `${serverUrl}/api/v4/client_perf`, + expectedRequest, + ); + }); }); - it('different metrics do not interfere', async () => { - const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp); - expectedRequest.body.histograms = [measure1, measure2]; + describe('other metrics', () => { + const serverUrl1 = 'http://www.someserverurl.com/'; + const expectedUrl1 = `${serverUrl1}/api/v4/client_perf`; - PerformanceMetricsManager.startMetric('mobile_channel_switch'); - jest.advanceTimersByTime(50); - PerformanceMetricsManager.startMetric('mobile_team_switch'); - jest.advanceTimersByTime(50); + const serverUrl2 = 'http://www.otherserverurl.com/'; + const expectedUrl2 = `${serverUrl2}/api/v4/client_perf`; - PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); - await TestHelper.tick(); - jest.advanceTimersByTime(100); - PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl1); - await TestHelper.tick(); + const measure1: PerformanceReportMeasure = { + metric: 'mobile_channel_switch', + timestamp: TEST_EPOCH + 100, + value: 100, + }; - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); - }); + const measure2: PerformanceReportMeasure = { + metric: 'mobile_team_switch', + timestamp: TEST_EPOCH + 150 + 50, + value: 150, + }; - it('metrics to different servers do not interfere', async () => { - const expectedRequest1 = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); - expectedRequest1.body.histograms = [measure1]; + beforeEach(async () => { + NetworkManager.createClient(serverUrl1); + NetworkManager.createClient(serverUrl2); + const {operator: operator1} = await TestHelper.setupServerDatabase(serverUrl1); + const {operator: operator2} = await TestHelper.setupServerDatabase(serverUrl2); + await operator1.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false}); + await operator2.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false}); + }); + afterEach(async () => { + NetworkManager.invalidateClient(serverUrl1); + NetworkManager.invalidateClient(serverUrl2); - const expectedRequest2 = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1); - expectedRequest2.body.histograms = [measure2]; + performance.clearMarks(); + }); - PerformanceMetricsManager.startMetric('mobile_channel_switch'); - jest.advanceTimersByTime(50); - PerformanceMetricsManager.startMetric('mobile_team_switch'); - jest.advanceTimersByTime(50); + it('do not send metrics when we do not start them', async () => { + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); - PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); - await TestHelper.tick(); - jest.advanceTimersByTime(100); - PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl2); - await TestHelper.tick(); + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); - jest.advanceTimersByTime(INTERVAL_TIME); - await TestHelper.tick(); - expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest1); - expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl2, expectedRequest2); + it('send metric after it has been started', async () => { + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); + expectedRequest.body.histograms = [measure1]; + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + jest.advanceTimersByTime(100); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); + }); + + it('a second end metric does not generate a second measure', async () => { + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); + expectedRequest.body.histograms = [measure1]; + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + jest.advanceTimersByTime(100); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + jest.advanceTimersByTime(100); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); + }); + + it('different metrics do not interfere', async () => { + const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp); + expectedRequest.body.histograms = [measure1, measure2]; + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + jest.advanceTimersByTime(50); + PerformanceMetricsManager.startMetric('mobile_team_switch'); + jest.advanceTimersByTime(50); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + jest.advanceTimersByTime(100); + PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl1); + await TestHelper.tick(); + + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest); + }); + + it('metrics to different servers do not interfere', async () => { + const expectedRequest1 = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1); + expectedRequest1.body.histograms = [measure1]; + + const expectedRequest2 = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1); + expectedRequest2.body.histograms = [measure2]; + + PerformanceMetricsManager.startMetric('mobile_channel_switch'); + jest.advanceTimersByTime(50); + PerformanceMetricsManager.startMetric('mobile_team_switch'); + jest.advanceTimersByTime(50); + + PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1); + await TestHelper.tick(); + jest.advanceTimersByTime(100); + PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl2); + await TestHelper.tick(); + + jest.advanceTimersByTime(INTERVAL_TIME); + await TestHelper.tick(); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest1); + expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl2, expectedRequest2); + }); }); }); diff --git a/app/managers/performance_metrics_manager/index.ts b/app/managers/performance_metrics_manager/index.ts index c73619d64..b9bf4c577 100644 --- a/app/managers/performance_metrics_manager/index.ts +++ b/app/managers/performance_metrics_manager/index.ts @@ -5,11 +5,15 @@ import RNUtils from '@mattermost/rnutils'; import {AppState, type AppStateStatus} from 'react-native'; import performance from 'react-native-performance'; -import {logWarning} from '@utils/log'; +import {logDebug, logWarning} from '@utils/log'; import Batcher from './performance_metrics_batcher'; +import type {NetworkRequestMetrics} from './constant'; +import type {MarkOptions} from 'react-native-performance/lib/typescript/performance'; + type Target = 'HOME' | 'CHANNEL' | 'THREAD' | undefined; + type MetricName = 'mobile_channel_switch' | 'mobile_team_switch'; @@ -22,7 +26,7 @@ class PerformanceMetricsManager { private lastAppStateIsActive = AppState.currentState === 'active'; constructor() { - AppState.addEventListener('change', (appState) => this.onAppStateChange(appState)); + AppState.addEventListener('change', (appState: AppStateStatus) => this.onAppStateChange(appState)); } private onAppStateChange(appState: AppStateStatus) { @@ -104,6 +108,31 @@ class PerformanceMetricsManager { performance.clearMarks(metricName); performance.clearMeasures(measureName); } + + public startTimeToInteraction(options?: MarkOptions) { + performance.mark('tti', options); + } + + public measureTimeToInteraction() { + try { + const result = performance.measure('TTI', 'tti'); + performance.clearMarks('tti'); + performance.clearMeasures('TTI'); + logDebug('Time to Interaction', result.duration); + return result; + } catch { + return undefined; + } + } + + public collectNetworkRequestData = (name: NetworkRequestMetrics, value: number, {serverUrl, groupLabel}: NetworkRequestDataOtherInfo) => { + this.ensureBatcher(serverUrl).addToBatch({ + metric: name, + value, + timestamp: Date.now(), + label: {network_request_group: groupLabel}, + }); + }; } export const testExports = { diff --git a/app/managers/performance_metrics_manager/performance_metrics_batcher.ts b/app/managers/performance_metrics_manager/performance_metrics_batcher.ts index 7129fbaab..5f87cc343 100644 --- a/app/managers/performance_metrics_manager/performance_metrics_batcher.ts +++ b/app/managers/performance_metrics_manager/performance_metrics_batcher.ts @@ -21,6 +21,16 @@ class Batcher { this.serverUrl = serverUrl; } + private async fetchClientPerformanceSetting() { + const database = DatabaseManager.serverDatabases[this.serverUrl]?.database; + if (!database) { + return false; + } + + const value = await getConfigValue(database, 'EnableClientMetrics'); + return value === 'true'; + } + private started() { return Boolean(this.sendTimeout); } @@ -46,13 +56,7 @@ class Batcher { // Empty the batch as soon as possible to avoid race conditions this.batch = []; - const database = DatabaseManager.serverDatabases[this.serverUrl]?.database; - if (!database) { - return; - } - - const clientPerformanceSetting = await getConfigValue(database, 'EnableClientMetrics'); - if (clientPerformanceSetting !== 'true') { + if (!await this.fetchClientPerformanceSetting()) { return; } diff --git a/app/managers/performance_metrics_manager/types.d.ts b/app/managers/performance_metrics_manager/types.d.ts index fabff0a3b..d20fb4cf5 100644 --- a/app/managers/performance_metrics_manager/types.d.ts +++ b/app/managers/performance_metrics_manager/types.d.ts @@ -5,6 +5,7 @@ type PerformanceReportMeasure = { metric: string; value: number; timestamp: number; + label?: Record; } type PerformanceReport = { @@ -21,3 +22,8 @@ type PerformanceReport = { counters: PerformanceReportMeasure[]; histograms: PerformanceReportMeasure[]; } + +type NetworkRequestDataOtherInfo = { + serverUrl: string; + groupLabel: RequestGroupLabel; +} diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index 95ab1a722..1fe2a252f 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -109,7 +109,7 @@ class WebsocketManager { } }; - public openAll = async (groupLabel?: string) => { + public openAll = async (groupLabel?: BaseRequestGroupLabel) => { let queued = 0; for await (const clientUrl of Object.keys(this.clients)) { const activeServerUrl = await DatabaseManager.getActiveServerUrl(); @@ -148,7 +148,7 @@ class WebsocketManager { } }; - public initializeClient = async (serverUrl: string, groupLabel = 'reconnection') => { + public initializeClient = async (serverUrl: string, groupLabel: BaseRequestGroupLabel = 'WebSocket Reconnect') => { const client: WebSocketClient = this.clients[serverUrl]; clearTimeout(this.connectionTimerIDs[serverUrl]); delete this.connectionTimerIDs[serverUrl]; @@ -271,7 +271,7 @@ class WebsocketManager { } this.isBackgroundTimerRunning = false; if (this.netConnected) { - this.openAll('reconnection'); + this.openAll('WebSocket Reconnect'); } return; diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 9590e4804..01eb11b13 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -390,9 +390,9 @@ describe('Actions.Calls', () => { const {result} = renderHook(() => useCallsConfig('server1')); await act(async () => { - await CallsActions.loadConfig('server1', false, 'calls'); + await CallsActions.loadConfig('server1', false, 'Server Switch'); }); - expect(mockClient.getCallsConfig).toHaveBeenCalledWith('calls'); + expect(mockClient.getCallsConfig).toHaveBeenCalledWith('Server Switch'); assert.equal(result.current.DefaultEnabled, true); assert.equal(result.current.AllowEnableCalls, true); }); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 860ce378b..0f4424d28 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -53,7 +53,7 @@ import type {IntlShape} from 'react-intl'; let connection: CallsConnection | null = null; export const getConnectionForTesting = () => connection; -export const loadConfig = async (serverUrl: string, force = false, groupLabel?: string) => { +export const loadConfig = async (serverUrl: string, force = false, groupLabel?: RequestGroupLabel) => { const now = Date.now(); const config = getCallsConfig(serverUrl); @@ -77,7 +77,7 @@ export const loadConfig = async (serverUrl: string, force = false, groupLabel?: } }; -export const loadCalls = async (serverUrl: string, userId: string, groupLabel?: string) => { +export const loadCalls = async (serverUrl: string, userId: string, groupLabel?: RequestGroupLabel) => { let resp: CallChannelState[] = []; try { const client = NetworkManager.getClient(serverUrl); @@ -192,7 +192,7 @@ export const createCallAndAddToIds = (channelId: string, call: CallState, ids?: return convertedCall; }; -export const loadConfigAndCalls = async (serverUrl: string, userId: string, groupLabel?: string) => { +export const loadConfigAndCalls = async (serverUrl: string, userId: string, groupLabel?: RequestGroupLabel) => { const res = await checkIsCallsPluginEnabled(serverUrl); if (res.data) { loadConfig(serverUrl, true, groupLabel); diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 9937bec45..a6cb3f84f 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -7,10 +7,10 @@ import type {RTCIceServer} from 'react-native-webrtc'; export interface ClientCallsMix { getEnabled: () => Promise; - getCalls: (groupLabel?: string) => Promise; + getCalls: (groupLabel?: RequestGroupLabel) => Promise; getCallForChannel: (channelId: string) => Promise; - getCallsConfig: (groupLabel?: string) => Promise; - getVersion: (groupLabel?: string) => Promise; + getCallsConfig: (groupLabel?: RequestGroupLabel) => Promise; + getVersion: (groupLabel?: RequestGroupLabel) => Promise; enableChannelCalls: (channelId: string, enable: boolean) => Promise; endCall: (channelId: string) => Promise; genTURNCredentials: () => Promise; @@ -38,7 +38,7 @@ const ClientCalls = (superclass: any) => class extends superclass { } }; - getCalls = async (groupLabel?: string) => { + getCalls = async (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getCallsRoute()}/channels?mobilev2=true`, {method: 'get', groupLabel}, @@ -52,14 +52,14 @@ const ClientCalls = (superclass: any) => class extends superclass { ); }; - getCallsConfig = async (groupLabel?: string) => { + getCallsConfig = async (groupLabel?: RequestGroupLabel) => { return this.doFetch( `${this.getCallsRoute()}/config`, {method: 'get', groupLabel}, ) as CallsConfig; }; - getVersion = async (groupLabel?: string) => { + getVersion = async (groupLabel?: RequestGroupLabel) => { try { return this.doFetch( `${this.getCallsRoute()}/version`, diff --git a/app/products/calls/components/call_notification/call_notification.tsx b/app/products/calls/components/call_notification/call_notification.tsx index fbd47a66c..238cf1ae1 100644 --- a/app/products/calls/components/call_notification/call_notification.tsx +++ b/app/products/calls/components/call_notification/call_notification.tsx @@ -157,7 +157,7 @@ export const CallNotification = ({ const onContainerPress = useCallback(async () => { if (incomingCall.serverUrl !== serverUrl) { await DatabaseManager.setActiveServerDatabase(incomingCall.serverUrl); - await WebsocketManager.initializeClient(incomingCall.serverUrl, 'calls'); + await WebsocketManager.initializeClient(incomingCall.serverUrl, 'Server Switch'); } switchToChannelById(incomingCall.serverUrl, incomingCall.channelID); }, [incomingCall, serverUrl]); diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 15b099dd9..4b4a388db 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -466,7 +466,7 @@ const CallScreen = ({ await popTopScreen(Screens.THREAD); } await DatabaseManager.setActiveServerDatabase(currentCall.serverUrl); - WebsocketManager.initializeClient(currentCall.serverUrl, 'calls'); + WebsocketManager.initializeClient(currentCall.serverUrl, 'Server Switch'); await goToScreen(Screens.THREAD, callThreadOptionTitle, {rootId: currentCall.threadId}); }, [currentCall?.serverUrl, currentCall?.threadId, fromThreadScreen, componentId, callThreadOptionTitle]); diff --git a/app/queries/servers/channel.test.ts b/app/queries/servers/channel.test.ts index 0c01a5e2f..2d3d5c86c 100644 --- a/app/queries/servers/channel.test.ts +++ b/app/queries/servers/channel.test.ts @@ -276,7 +276,9 @@ describe('prepareMyChannelsForTeam', () => { (operator.handleMyChannel as jest.Mock).mockReturnValue(myChannelRecords); (operator.handleMyChannelSettings as jest.Mock).mockReturnValue(myChannelSettingsRecords); + query = jest.fn().mockResolvedValueOnce(Object.entries(allChannelsForTeam).map((c) => c[1])).mockResolvedValueOnce(Object.entries(allChannelsInfoForTeam).map((i) => i[1])); const result = await prepareMyChannelsForTeam(operator, teamId, channels, channelMembers, true); + query = jest.fn(); expect(result).toEqual([channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords]); expect(operator.handleChannel).toHaveBeenCalledWith({channels, prepareRecordsOnly: true}); @@ -967,7 +969,7 @@ describe('Channel Functions', () => { Q.where('team_id', teamId), Q.where('delete_at', Q.eq(0)), Q.where('type', General.OPEN_CHANNEL), - Q.where('channel_id', Q.notEq('ignoreid')), + Q.where('id', Q.notEq('ignoreid')), ), Q.sortBy('display_name', Q.asc), ); diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 8323211f6..32e28b700 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -81,37 +81,30 @@ export function prepareMissingChannelsForAllTeams(operator: ServerDataOperator, return prepareChannels(operator, channels, channelInfos, memberships, memberships, isCRTEnabled); } -export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[], isCRTEnabled?: boolean) => { - const {database} = operator; - - const channelsQuery = queryAllChannelsForTeam(database, teamId); - const allChannelsForTeam = (await channelsQuery.fetch()). - reduce((map: Record, channel) => { - map[channel.id] = channel; - return map; - }, {}); - - const channelInfosQuery = queryAllChannelsInfoForTeam(database, teamId); - const allChannelsInfoForTeam = (await channelInfosQuery.fetch()). - reduce((map: Record, info) => { - map[info.id] = info; - return map; - }, {}); - +const buildChannelInfos = async (database: Database, channels: Channel[]) => { const channelInfos: ChannelInfo[] = []; - const memberships = channelMembers.map((cm) => { - return {...cm, id: cm.channel_id}; - }); + + const channelsQuery = await queryAllChannels(database); + const storedChannelsMap = channelsQuery.reduce>((map, channel) => { + map[channel.id] = channel; + return map; + }, {}); + + const channelInfosQuery = await queryAllChannelsInfo(database); + const storedChannelInfosMap = channelInfosQuery.reduce>((map, info) => { + map[info.id] = info; + return map; + }, {}); for (const c of channels) { - const storedChannel = allChannelsForTeam[c.id]; + const storedChannel = storedChannelsMap[c.id]; let storedInfo: ChannelInfoModel | undefined; let member_count = 0; let guest_count = 0; let pinned_post_count = 0; let files_count = 0; if (storedChannel) { - storedInfo = allChannelsInfoForTeam[c.id]; + storedInfo = storedChannelInfosMap[c.id]; if (storedInfo) { member_count = storedInfo.memberCount; guest_count = storedInfo.guestCount; @@ -131,6 +124,28 @@ export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, tea }); } + return channelInfos; +}; + +export const prepareAllMyChannels = async (operator: ServerDataOperator, channels: Channel[], channelMemberships: ChannelMembership[], isCRTEnabled?: boolean) => { + const {database} = operator; + + const fetchedChannelIds = new Set(channels.map((c) => c.id)); + const memberships = channelMemberships.filter((m) => fetchedChannelIds.has(m.channel_id)).map((m) => ({...m, id: m.channel_id})); + + const channelInfos = await buildChannelInfos(database, channels); + + return prepareChannels(operator, channels, channelInfos, channelMemberships, memberships, isCRTEnabled); +}; + +export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[], isCRTEnabled?: boolean) => { + const {database} = operator; + + const channelInfos = await buildChannelInfos(database, channels); + const memberships = channelMembers.map((cm) => { + return {...cm, id: cm.channel_id}; + }); + return prepareChannels(operator, channels, channelInfos, channelMembers, memberships, isCRTEnabled); }; @@ -316,7 +331,7 @@ export const getDefaultChannelForTeam = async (database: Database, teamId: strin ]; if (ignoreId) { - clauses.push(Q.where('channel_id', Q.notEq(ignoreId))); + clauses.push(Q.where('id', Q.notEq(ignoreId))); } const myChannels = await database.get(CHANNEL).query( diff --git a/app/queries/servers/entry.ts b/app/queries/servers/entry.ts index 60ed5ffb0..4e1c5992a 100644 --- a/app/queries/servers/entry.ts +++ b/app/queries/servers/entry.ts @@ -5,10 +5,10 @@ import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/manager'; import {prepareCategoriesAndCategoriesChannels} from './categories'; -import {prepareDeleteChannel, prepareMyChannelsForTeam} from './channel'; +import {prepareAllMyChannels, prepareDeleteChannel, queryAllChannels, queryChannelsById} from './channel'; import {prepareMyPreferences} from './preference'; import {resetLastFullSync} from './system'; -import {prepareDeleteTeam, prepareMyTeams} from './team'; +import {prepareDeleteTeam, prepareMyTeams, queryMyTeams, queryTeamsById} from './team'; import {prepareUsers} from './user'; import type {MyChannelsRequest} from '@actions/remote/channel'; @@ -18,13 +18,10 @@ import type {MyUserRequest} from '@actions/remote/user'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Model} from '@nozbe/watermelondb'; import type ChannelModel from '@typings/database/models/servers/channel'; -import type TeamModel from '@typings/database/models/servers/team'; type PrepareModelsArgs = { operator: ServerDataOperator; initialTeamId?: string; - removeTeams?: TeamModel[]; - removeChannels?: ChannelModel[]; teamData?: MyTeamsRequest; chData?: MyChannelsRequest; prefData?: MyPreferencesRequest; @@ -32,6 +29,13 @@ type PrepareModelsArgs = { isCRTEnabled?: boolean; } +type PrepareModelsForDeletionArgs = { + operator: ServerDataOperator; + initialTeamId?: string; + teamData?: MyTeamsRequest; + chData?: MyChannelsRequest; +} + const { CHANNEL_BOOKMARK, POST, @@ -44,21 +48,48 @@ const { MY_CHANNEL, } = MM_TABLES.SERVER; -export async function prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData, isCRTEnabled}: PrepareModelsArgs): Promise>> { +export async function prepareEntryModelsForDeletion({operator, teamData, chData}: PrepareModelsForDeletionArgs): Promise>> { const modelPromises: Array> = []; + const {database} = operator; + let teamIdsToDelete: Set|undefined; + + if (teamData?.teams?.length && teamData.memberships?.length) { + const myTeams = await queryMyTeams(database).fetch(); + const joinedTeams = new Set(teamData.memberships?.filter((m) => m.delete_at === 0).map((m) => m.team_id)); + const myTeamsToDelete = myTeams.filter((m) => !joinedTeams.has(m.id)); + teamIdsToDelete = new Set(myTeamsToDelete.map((m) => m.id)); + const removeTeams = await queryTeamsById(database, Array.from(teamIdsToDelete)).fetch(); - if (removeTeams?.length) { removeTeams.forEach((team) => { modelPromises.push(prepareDeleteTeam(team)); }); } - if (removeChannels?.length) { - removeChannels.forEach((channel) => { - modelPromises.push(prepareDeleteChannel(channel)); - }); + if (chData?.channels?.length && chData.memberships?.length) { + const {channels} = chData; + const fetchedChannelIds = new Set(channels.map((c) => c.id)); + const channelsQuery = await queryAllChannels(database); + const storedChannelsMap = channelsQuery.reduce>((map, channel) => { + map[channel.id] = channel; + return map; + }, {}); + + const removeChannelIds: string[] = Object.keys(storedChannelsMap).filter((id) => !fetchedChannelIds.has(id) && !teamIdsToDelete?.has(storedChannelsMap[id]?.teamId)); + let removeChannels: ChannelModel[]|undefined; + if (removeChannelIds?.length) { + removeChannels = await queryChannelsById(database, removeChannelIds).fetch(); + removeChannels.forEach((c) => { + modelPromises.push(prepareDeleteChannel(c)); + }); + } } + return modelPromises; +} + +export async function prepareEntryModels({operator, teamData, chData, prefData, meData, isCRTEnabled}: PrepareModelsArgs): Promise>> { + const modelPromises: Array> = []; + if (teamData?.teams?.length && teamData.memberships?.length) { modelPromises.push(...prepareMyTeams(operator, teamData.teams, teamData.memberships)); } @@ -68,9 +99,8 @@ export async function prepareModels({operator, initialTeamId, removeTeams, remov } if (chData?.channels?.length && chData.memberships?.length) { - if (initialTeamId) { - modelPromises.push(...await prepareMyChannelsForTeam(operator, initialTeamId, chData.channels, chData.memberships, isCRTEnabled)); - } + const {channels, memberships} = chData; + modelPromises.push(...await prepareAllMyChannels(operator, channels, memberships, isCRTEnabled)); } if (prefData?.preferences?.length) { diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index 5617908af..4fcd5467e 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -109,7 +109,7 @@ const ChannelListScreen = (props: ChannelProps) => { } } return false; - }, [intl]); + }, [intl, navigation]); const animated = useAnimatedStyle(() => { if (!isFocused) { @@ -136,7 +136,7 @@ const ChannelListScreen = (props: ChannelProps) => { if (!props.hasTeams) { resetToTeams(); } - }, [Boolean(props.hasTeams)]); + }, [props.hasTeams]); useEffect(() => { const back = BackHandler.addEventListener('hardwareBackPress', handleBackPress); @@ -172,6 +172,7 @@ const ChannelListScreen = (props: ChannelProps) => { useEffect(() => { PerformanceMetricsManager.finishLoad('HOME', serverUrl); + PerformanceMetricsManager.measureTimeToInteraction(); }, []); return ( @@ -201,7 +202,7 @@ const ChannelListScreen = (props: ChannelProps) => { moreThanOneTeam={props.hasMoreThanOneTeam} hasChannels={props.hasChannels} /> - {isTablet && + {isTablet && props.hasChannels && } {props.showIncomingCalls && !isTablet && diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx index 27d0c6efd..42f0a85da 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx @@ -179,7 +179,7 @@ const ServerItem = ({ setBadge({isUnread, mentions}); }; - const logoutServer = async () => { + const logoutServer = useCallback(async () => { Navigation.updateProps(Screens.HOME, {extra: undefined}); await logout(server.url); @@ -188,14 +188,14 @@ const ServerItem = ({ } else { DeviceEventEmitter.emit(Events.SWIPEABLE, ''); } - }; + }, [isActive, server.url]); - const removeServer = async () => { + const removeServer = useCallback(async () => { const skipLogoutFromServer = server.lastActiveAt === 0; await dismissBottomSheet(); Navigation.updateProps(Screens.HOME, {extra: undefined}); await logout(server.url, skipLogoutFromServer, true); - }; + }, [server.lastActiveAt, server.url]); const startTutorial = () => { viewRef.current?.measureInWindow((x, y, w, h) => { @@ -222,7 +222,7 @@ const ServerItem = ({ setShowTutorial(true); }); } - }, [showTutorial]); + }, [highlight, isTablet, tutorialWatched]); useLayoutEffect(() => { if (showTutorial && !tutorialShown.current) { @@ -239,7 +239,7 @@ const ServerItem = ({ } return style; - }, [isActive]); + }, [isActive, styles.active, styles.container]); const serverStyle = useMemo(() => { const style: StyleProp = [styles.row]; @@ -248,7 +248,7 @@ const ServerItem = ({ } return style; - }, [server.lastActiveAt]); + }, [server.lastActiveAt, styles.offline, styles.row]); const handleLogin = useCallback(async () => { swipeable.current?.close(); @@ -275,7 +275,7 @@ const ServerItem = ({ canReceiveNotifications(server.url, result.canReceiveNotifications as string, intl); loginToServer(theme, server.url, displayName, data.config!, data.license!); - }, [server, theme, intl]); + }, [server.url, intl, theme, displayName]); const handleDismissTutorial = useCallback(() => { swipeable.current?.close(); @@ -286,15 +286,15 @@ const ServerItem = ({ const handleEdit = useCallback(() => { DeviceEventEmitter.emit(Events.SWIPEABLE, ''); editServer(theme, server); - }, [server]); + }, [server, theme]); const handleLogout = useCallback(async () => { alertServerLogout(server.displayName, logoutServer, intl); - }, [isActive, intl, server]); + }, [server.displayName, logoutServer, intl]); const handleRemove = useCallback(() => { alertServerRemove(server.displayName, removeServer, intl); - }, [isActive, server, intl]); + }, [server.displayName, removeServer, intl]); const handleShowTutorial = useCallback(() => { swipeable.current?.openRight(); @@ -311,12 +311,12 @@ const ServerItem = ({ await dismissBottomSheet(); Navigation.updateProps(Screens.HOME, {extra: undefined}); DatabaseManager.setActiveServerDatabase(server.url); - WebsocketManager.initializeClient(server.url, 'entry'); + WebsocketManager.initializeClient(server.url, 'Server Switch'); return; } handleLogin(); - }, [server, isActive, theme, intl]); + }, [isActive, server.lastActiveAt, server.url, handleLogin]); const onSwipeableWillOpen = useCallback(() => { DeviceEventEmitter.emit(Events.SWIPEABLE, server.url); diff --git a/app/utils/deep_link/index.test.ts b/app/utils/deep_link/index.test.ts index e2d97452c..55a18d832 100644 --- a/app/utils/deep_link/index.test.ts +++ b/app/utils/deep_link/index.test.ts @@ -127,7 +127,7 @@ describe('handleDeepLink', () => { const result = await handleDeepLink('https://existingserver.com/team/channels/town-square'); expect(dismissAllModalsAndPopToRoot).toHaveBeenCalled(); expect(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('https://existingserver.com'); - expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('https://existingserver.com', 'deeplink'); + expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('https://existingserver.com', 'DeepLink'); expect(result).toEqual({error: false}); }); diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts index 1e5223420..ab37a1aef 100644 --- a/app/utils/deep_link/index.ts +++ b/app/utils/deep_link/index.ts @@ -61,7 +61,7 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, if (existingServerUrl !== currentServerUrl && NavigationStore.getVisibleScreen()) { await dismissAllModalsAndPopToRoot(); DatabaseManager.setActiveServerDatabase(existingServerUrl); - WebsocketManager.initializeClient(existingServerUrl, 'deeplink'); + WebsocketManager.initializeClient(existingServerUrl, 'DeepLink'); await NavigationStore.waitUntilScreenHasLoaded(Screens.HOME); } diff --git a/app/utils/file/file_picker/index.test.ts b/app/utils/file/file_picker/index.test.ts index 9887f7447..681d66374 100644 --- a/app/utils/file/file_picker/index.test.ts +++ b/app/utils/file/file_picker/index.test.ts @@ -41,6 +41,13 @@ jest.mock('@utils/log', () => ({ jest.mock('@mattermost/rnutils', () => ({ getRealFilePath: jest.fn(), isRunningInSplitView: jest.fn().mockReturnValue({isSplit: false, isTablet: false}), + getConstants: jest.fn().mockReturnValue({ + appGroupIdentifier: 'group.mattermost.rnbeta', + appGroupSharedDirectory: { + sharedDirectory: '', + databasePath: '', + }, + }), })); describe('FilePickerUtil', () => { diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 55a451a69..11f001cff 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -181,6 +181,11 @@ lane :configure do json['ShowOnboarding'] = true end + # Configure Show Onboarding + if ENV['COLLECT_NETWORK_METRICS'] == 'true' + json['CollectNetworkMetrics'] = true + end + # Configure the auth schemes auth_scheme = ENV['APP_AUTH_SCHEME'].to_s.empty? ? 'mmauth' : ENV['APP_AUTH_SCHEME'] auth_scheme_dev = ENV['APP_AUTH_SCHEME_BETA'].to_s.empty? ? 'mmauthbeta' : ENV['APP_AUTH_SCHEME_BETA'] diff --git a/types/api/client.d.ts b/types/api/client.d.ts index 33ca82f8d..d9cc36cc5 100644 --- a/types/api/client.d.ts +++ b/types/api/client.d.ts @@ -3,13 +3,16 @@ type logLevel = 'ERROR' | 'WARNING' | 'INFO'; +type BaseRequestGroupLabel = 'Login' | 'Cold Start' | 'Notification' | 'DeepLink' | 'WebSocket Reconnect' | 'Server Switch'; +type RequestGroupLabel = BaseRequestGroupLabel | `${BaseRequestGroupLabel} Deferred`; + type ClientOptions = { body?: any; method?: string; noRetry?: boolean; timeoutInterval?: number; headers?: Record; - groupLabel?: string; + groupLabel?: RequestGroupLabel; }; type ClientErrorIntl =