// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Model} from '@nozbe/watermelondb'; import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, MyChannelsRequest, switchToChannelById} from '@actions/remote/channel'; import {fetchPostsForUnreadChannels} from '@actions/remote/post'; import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference'; import {fetchRoles} from '@actions/remote/role'; import {fetchConfigAndLicense} from '@actions/remote/systems'; import {fetchAllTeams, fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from '@actions/remote/team'; import {fetchAndSwitchToThread, fetchNewThreads} from '@actions/remote/thread'; import {fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user'; import {gqlAllChannels} from '@client/graphQL/entry'; import {Preferences, Screens} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import {PUSH_PROXY_RESPONSE_NOT_AVAILABLE, PUSH_PROXY_RESPONSE_UNKNOWN, PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_UNKNOWN, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy'; import DatabaseManager from '@database/manager'; import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference'; import {selectDefaultTeam} from '@helpers/api/team'; import {DEFAULT_LOCALE} from '@i18n'; import NetworkManager from '@managers/network_manager'; import {getDeviceToken} from '@queries/app/global'; import {queryAllServers} from '@queries/app/servers'; import {getMyChannel, prepareMyChannelsForTeam, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel'; import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry'; import {getHasCRTChanged} from '@queries/servers/preference'; import {getConfig, getCurrentUserId, getPushVerificationStatus, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system'; import {deleteMyTeams, getAvailableTeamIds, getMyTeamById, getNthLastChannelFromTeam, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team'; import {getIsCRTEnabled} from '@queries/servers/thread'; import NavigationStore from '@store/navigation_store'; import {isDMorGM} from '@utils/channel'; import {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql'; import {isTablet} from '@utils/helpers'; import {logDebug} from '@utils/log'; import {emitNotificationError} from '@utils/notification'; import {processIsCRTEnabled} from '@utils/thread'; import {fetchGroupsForMember} from '../groups'; import type ClientError from '@client/rest/error'; export type AppEntryData = { initialTeamId: string; teamData: MyTeamsRequest; chData?: MyChannelsRequest; prefData: MyPreferencesRequest; meData: MyUserRequest; removeTeamIds?: string[]; removeChannelIds?: string[]; isCRTEnabled: boolean; } export type AppEntryError = { error: Error | ClientError | string; } export type EntryResponse = { models: Model[]; initialTeamId: string; initialChannelId: string; prefData: MyPreferencesRequest; teamData: MyTeamsRequest; chData?: MyChannelsRequest; meData?: MyUserRequest; } | { error: unknown; } const FETCH_MISSING_DM_TIMEOUT = 2500; export const FETCH_UNREADS_TIMEOUT = 2500; export const teamsToRemove = async (serverUrl: string, removeTeamIds?: string[]) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return []; } const {database} = operator; 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; } } return []; }; export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0): Promise => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } const {database} = operator; const lastDisconnectedAt = since || await getWebSocketLastDisconnected(database); const fetchedData = await fetchAppEntryData(serverUrl, lastDisconnectedAt, teamId); if ('error' in fetchedData) { return {error: fetchedData.error}; } const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds, isCRTEnabled} = fetchedData; const error = teamData.error || chData?.error || prefData.error || meData.error; if (error) { return {error}; } const rolesData = await fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData.user, true); let initialChannelId = channelId; if (!chData?.channels?.find((c) => c.id === channelId)) { initialChannelId = ''; } if (initialTeamId !== teamId || !initialChannelId) { initialChannelId = await getNthLastChannelFromTeam(database, initialTeamId); } 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}; }; export const fetchAppEntryData = async (serverUrl: string, sinceArg: number, initialTeamId = ''): 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); const prefData = await fetchMyPreferences(serverUrl, fetchOnly); const isCRTEnabled = Boolean(prefData.preferences && processIsCRTEnabled(prefData.preferences, confReq.config)); 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 / channels for current team / user preferences / user const promises: [Promise, Promise, Promise] = [ fetchMyTeams(serverUrl, fetchOnly), initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, since, fetchOnly, false, isCRTEnabled) : Promise.resolve(undefined), fetchMe(serverUrl, fetchOnly), ]; const removeTeamIds: string[] = []; const resolution = await Promise.all(promises); const [teamData, , meData] = resolution; let [, chData] = resolution; 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.TEAMS_ORDER, '', '') as string; 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); } } const removedFromTeam = teamData.memberships?.filter((m) => m.delete_at > 0); if (removedFromTeam?.length) { removeTeamIds.push(...removedFromTeam.map((m) => m.team_id)); } let data: AppEntryData = { initialTeamId, teamData, chData, prefData, meData, removeTeamIds, isCRTEnabled, }; if (teamData.teams?.length === 0 && !teamData.error) { // User is no longer a member of any team const myTeams = await queryMyTeams(database).fetch(); removeTeamIds.push(...(myTeams.map((myTeam) => myTeam.id) || [])); return { ...data, initialTeamId: '', removeTeamIds, }; } const inTeam = teamData.teams?.find((t) => t.id === initialTeamId); const chError = chData?.error as ClientError | undefined; if ((!inTeam && !teamData.error) || 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); 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; }; export const fetchAlternateTeamData = async ( serverUrl: string, availableTeamIds: string[], removeTeamIds: string[], includeDeleted = true, since = 0, fetchOnly = false, isCRTEnabled?: boolean) => { let initialTeamId = ''; let chData; for (const teamId of availableTeamIds) { // eslint-disable-next-line no-await-in-loop chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly, false, isCRTEnabled); const chError = chData.error as ClientError | undefined; if (chError?.status_code === 403) { removeTeamIds.push(teamId); } else { initialTeamId = teamId; break; } } if (chData) { return {initialTeamId, chData, removeTeamIds}; } return {initialTeamId, removeTeamIds}; }; export async function deferredAppEntryActions( serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined, config: ClientConfig, license: ClientLicense, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined, initialTeamId?: string, initialChannelId?: string) { // defer sidebar DM & GM profiles let channelsToFetchProfiles: Set|undefined; setTimeout(async () => { if (chData?.channels?.length && chData.memberships?.length) { const directChannels = chData.channels.filter(isDMorGM); channelsToFetchProfiles = new Set(directChannels); // defer fetching posts for unread channels on initial team fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId, true); } }, FETCH_UNREADS_TIMEOUT); // defer fetch channels and unread posts for other teams if (teamData.teams?.length && teamData.memberships?.length) { fetchTeamsChannelsAndUnreadPosts(serverUrl, since, teamData.teams, teamData.memberships, initialTeamId); } if (preferences && processIsCRTEnabled(preferences, config)) { if (initialTeamId) { await fetchNewThreads(serverUrl, initialTeamId, false); } if (teamData.teams?.length) { for await (const team of teamData.teams) { if (team.id !== initialTeamId) { // need to await here since GM/DM threads in different teams overlap await fetchNewThreads(serverUrl, team.id, false); } } } } await fetchAllTeams(serverUrl); await updateAllUsersSince(serverUrl, since); // Fetch groups for current user fetchGroupsForMember(serverUrl, currentUserId); setTimeout(async () => { if (channelsToFetchProfiles?.size) { const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license); fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId); } }, FETCH_MISSING_DM_TIMEOUT); } export const registerDeviceToken = async (serverUrl: string) => { let client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { return {error}; } const appDatabase = DatabaseManager.appDatabase?.database; if (appDatabase) { const deviceToken = await getDeviceToken(appDatabase); if (deviceToken) { client.attachDevice(deviceToken); } } return {error: undefined}; }; export const syncOtherServers = async (serverUrl: string) => { const database = DatabaseManager.appDatabase?.database; if (database) { const servers = await queryAllServers(database); for (const server of servers) { if (server.url !== serverUrl && server.lastActiveAt > 0) { registerDeviceToken(server.url); syncAllChannelMembersAndThreads(server.url); } } } }; const syncAllChannelMembersAndThreads = async (serverUrl: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return; } const config = await getConfig(database); if (config?.FeatureFlagGraphQL === 'true') { const error = await graphQLSyncAllChannelMembers(serverUrl); if (error) { logDebug('failed graphQL, falling back to rest', error); restSyncAllChannelMembers(serverUrl); } } else { restSyncAllChannelMembers(serverUrl); } }; const graphQLSyncAllChannelMembers = async (serverUrl: string) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return 'Server database not found'; } const response = await gqlAllChannels(serverUrl); if ('error' in response) { return response.error; } if (response.errors) { return response.errors[0].message; } const userId = await getCurrentUserId(operator.database); const channels = getMemberChannelsFromGQLQuery(response.data); const memberships = response.data.channelMembers?.map((m) => gqlToClientChannelMembership(m, userId)); if (channels && memberships) { const modelPromises = await prepareMyChannelsForTeam(operator, '', channels, memberships, undefined, true); const models = (await Promise.all(modelPromises)).flat(); if (models.length) { operator.batchRecords(models); } } return ''; }; const restSyncAllChannelMembers = async (serverUrl: string) => { let client; try { client = NetworkManager.getClient(serverUrl); } catch { return; } try { const myTeams = await client.getMyTeams(); const preferences = await client.getMyPreferences(); const config = await client.getClientConfigOld(); let excludeDirect = false; for (const myTeam of myTeams) { fetchMyChannelsForTeam(serverUrl, myTeam.id, false, 0, false, excludeDirect); excludeDirect = true; if (preferences && processIsCRTEnabled(preferences, config)) { fetchNewThreads(serverUrl, myTeam.id, false); } } } catch { // Do nothing } }; export async function verifyPushProxy(serverUrl: string) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return; } const {database} = operator; const ppVerification = await getPushVerificationStatus(database); if ( ppVerification !== PUSH_PROXY_STATUS_UNKNOWN && ppVerification !== '' ) { return; } const appDatabase = DatabaseManager.appDatabase?.database; if (!appDatabase) { return; } const deviceId = await getDeviceToken(appDatabase); if (!deviceId) { return; } let client; try { client = NetworkManager.getClient(serverUrl); } catch (err) { return; } try { const response = await client.ping(deviceId); const canReceiveNotifications = response?.data?.CanReceiveNotifications; switch (canReceiveNotifications) { case PUSH_PROXY_RESPONSE_NOT_AVAILABLE: operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_NOT_AVAILABLE}], prepareRecordsOnly: false}); return; case PUSH_PROXY_RESPONSE_UNKNOWN: return; default: operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}], prepareRecordsOnly: false}); } } catch (err) { // Do nothing } } export async function handleNotificationNavigation(serverUrl: string, selectedChannelId: string, selectedTeamId: string, notificationChannelId: string, notificationTeamId: string, rootId = '') { try { const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const myChannel = await getMyChannel(database, selectedChannelId); const myTeam = await getMyTeamById(database, selectedTeamId); const isCRTEnabled = await getIsCRTEnabled(database); const isThreadNotification = isCRTEnabled && Boolean(rootId); let switchedToScreen = false; let switchedToChannel = false; if (myChannel && myTeam) { if (isThreadNotification) { await fetchAndSwitchToThread(serverUrl, rootId, true); } else { switchedToChannel = true; await switchToChannelById(serverUrl, selectedChannelId, selectedTeamId); } switchedToScreen = true; } if (!switchedToScreen) { const isTabletDevice = await isTablet(); if (isTabletDevice || (notificationChannelId === selectedChannelId)) { // Make switch again to get the missing data and make sure the team is the correct one switchedToScreen = true; if (isThreadNotification) { await fetchAndSwitchToThread(serverUrl, rootId, true); } else { switchedToChannel = true; await switchToChannelById(serverUrl, notificationChannelId, notificationTeamId); } } else if (notificationTeamId !== selectedTeamId || notificationChannelId !== selectedChannelId) { // If in the end the selected team or channel is different than the one from the notification // we switch again await setCurrentTeamAndChannelId(operator, selectedTeamId, selectedChannelId); } } if (notificationTeamId !== selectedTeamId) { emitNotificationError('Team'); } else if (notificationChannelId !== selectedChannelId) { emitNotificationError('Channel'); } // Waiting for the screen to display fixes a race condition when fetching and storing data if (switchedToChannel) { await NavigationStore.waitUntilScreenHasLoaded(Screens.CHANNEL); } else if (switchedToScreen && isThreadNotification) { await NavigationStore.waitUntilScreenHasLoaded(Screens.THREAD); } return {}; } catch (error) { return {error}; } }