mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-14 15:10:17 +00:00
* fetch roles during entry to avoid UI observing inconsistent channel states first time channels are fetched for a team, roles should also be fetched at the same time instead of only relying in deferred fetch. That avoids having channels observed by the UI in an inconsistent state and rendering as read only channels. During reconnection the non forced entry fetch roles is a no-op since force is set to false. * separate fetch roles promise to log errors * returns not empty models in test
420 lines
20 KiB
TypeScript
420 lines
20 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {nativeApplicationVersion} from 'expo-application';
|
|
import {RESULTS, checkNotifications} from 'react-native-permissions';
|
|
|
|
import {deletePostsForChannel, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
|
|
import {fetchChannelById, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel';
|
|
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
|
|
import {fetchRoles} from '@actions/remote/role';
|
|
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
|
|
import {fetchMyTeams, handleKickFromTeam, type MyTeamsRequest} from '@actions/remote/team';
|
|
import {fetchMe, type MyUserRequest} from '@actions/remote/user';
|
|
import {General, 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} from '@helpers/api/preference';
|
|
import {selectDefaultTeam} from '@helpers/api/team';
|
|
import {DEFAULT_LOCALE} from '@i18n';
|
|
import NetworkManager from '@managers/network_manager';
|
|
import {getDeviceToken, getVoIPDeviceToken} from '@queries/app/global';
|
|
import {getChannelById, queryChannelsById, queryMyChannelsByChannelIds} from '@queries/servers/channel';
|
|
import {prepareEntryModels, truncateCrtRelatedTables} from '@queries/servers/entry';
|
|
import {getHasCRTChanged} from '@queries/servers/preference';
|
|
import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system';
|
|
import {getTeamChannelHistory} from '@queries/servers/team';
|
|
import {getCurrentUser} from '@queries/servers/user';
|
|
import {NavigationStore} from '@store/navigation_store';
|
|
import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
|
|
import {getFullErrorMessage} from '@utils/errors';
|
|
import {isMinimumServerVersion, isTablet} from '@utils/helpers';
|
|
import {logDebug, logError} from '@utils/log';
|
|
import {processIsCRTEnabled} from '@utils/thread';
|
|
|
|
import type {Database, Model} from '@nozbe/watermelondb';
|
|
|
|
export type AppEntryData = {
|
|
initialTeamId: string;
|
|
teamData: MyTeamsRequest;
|
|
chData?: MyChannelsRequest;
|
|
prefData: MyPreferencesRequest;
|
|
meData: MyUserRequest;
|
|
removeTeamIds?: string[];
|
|
removeChannelIds?: string[];
|
|
isCRTEnabled: boolean;
|
|
initialChannelId?: string;
|
|
gmConverted: boolean;
|
|
}
|
|
|
|
export type AppEntryError = {
|
|
error: unknown;
|
|
}
|
|
|
|
export type EntryResponse = {
|
|
models: Model[];
|
|
initialTeamId: string;
|
|
initialChannelId: string;
|
|
prefData: MyPreferencesRequest;
|
|
teamData: MyTeamsRequest;
|
|
chData?: MyChannelsRequest;
|
|
meData?: MyUserRequest;
|
|
gmConverted: boolean;
|
|
} | {
|
|
error: unknown;
|
|
}
|
|
|
|
export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0, groupLabel?: RequestGroupLabel): Promise<EntryResponse> => {
|
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const result = await entryRest(serverUrl, teamId, channelId, since, groupLabel);
|
|
|
|
// Fetch data retention policies
|
|
if (!result.error) {
|
|
const isDataRetentionEnabled = await getIsDataRetentionEnabled(database);
|
|
if (isDataRetentionEnabled) {
|
|
fetchDataRetentionPolicy(serverUrl, false, groupLabel);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
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 [confResp, prefData] = await Promise.all([
|
|
fetchConfigAndLicense(serverUrl, false, groupLabel),
|
|
fetchMyPreferences(serverUrl, true, groupLabel),
|
|
]);
|
|
|
|
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`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// let's start fetching in parallel all we can
|
|
const promises: [Promise<MyTeamsRequest>, Promise<MyUserRequest>] = [
|
|
fetchMyTeams(serverUrl, true, groupLabel),
|
|
fetchMe(serverUrl, true, groupLabel),
|
|
];
|
|
|
|
const [teamData, meData] = await Promise.all(promises);
|
|
const error = confResp.error || prefData.error || teamData.error || meData.error;
|
|
if (error) {
|
|
return {error};
|
|
}
|
|
|
|
let initialTeamId = teamId || '';
|
|
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 exists 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 fetchResult = await fetchChannelById(serverUrl, channelId);
|
|
|
|
// Although you can convert GM only to a private channel, a private channel can further 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 (fetchResult.channel?.type === General.PRIVATE_CHANNEL || fetchResult.channel?.type === General.OPEN_CHANNEL) {
|
|
initialTeamId = fetchResult.channel.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<string>(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;
|
|
}
|
|
}
|
|
|
|
let chData: MyChannelsRequest = {
|
|
channels: [],
|
|
memberships: [],
|
|
categories: [],
|
|
};
|
|
if (initialTeamId) {
|
|
chData = await fetchMyChannelsForTeam(serverUrl, initialTeamId, false, lastDisconnectedAt, true, false, isCRTEnabled, groupLabel);
|
|
}
|
|
|
|
initialChannelId = await entryInitialChannelId(database, initialChannelId, teamId, initialTeamId, meData?.user?.locale || '', chData?.channels, chData?.memberships);
|
|
|
|
const initialTeamData = initialTeamId ? {
|
|
teams: teamData.teams?.filter((t) => t.id === initialTeamId),
|
|
memberships: teamData.memberships?.filter((m) => m.team_id === initialTeamId),
|
|
} : teamData;
|
|
|
|
const dt = Date.now();
|
|
|
|
await handleAutotranslationChanges(serverUrl, meData, chData);
|
|
|
|
const rolesRequest = fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData?.user, false, false, groupLabel);
|
|
|
|
const modelPromises = await prepareEntryModels({operator, teamData: initialTeamData, chData, prefData, meData, isCRTEnabled});
|
|
const models = (await Promise.all(modelPromises)).flat();
|
|
|
|
const {error: rolesError} = await rolesRequest;
|
|
if (rolesError) {
|
|
logDebug('entryRest: failed to fetch roles', groupLabel, getFullErrorMessage(rolesError));
|
|
}
|
|
|
|
logDebug('Process models on entry', groupLabel, models.length, `${Date.now() - dt}ms`);
|
|
return {models, initialChannelId, initialTeamId, prefData, teamData, chData, meData, gmConverted};
|
|
} catch (error) {
|
|
logError('entryRest', groupLabel, error);
|
|
return {error};
|
|
}
|
|
};
|
|
|
|
export async function handleAutotranslationChanges(serverUrl: string, meData: MyUserRequest | undefined, chData: MyChannelsRequest | undefined) {
|
|
try {
|
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
// Config changes already handled in storeConfigAndLicense
|
|
const enableAutoTranslation = (await getConfigValue(operator.database, 'EnableAutoTranslation')) === 'true';
|
|
if (!enableAutoTranslation) {
|
|
return;
|
|
}
|
|
|
|
// If user locale changed, delete all the posts for all channels with autotranslation enabled
|
|
// so the posts can be refetch with the new translation
|
|
if (meData) {
|
|
const currentUser = await getCurrentUser(database);
|
|
if (currentUser && meData.user?.locale !== currentUser.locale) {
|
|
await deletePostsForChannelsWithAutotranslation(serverUrl);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (chData) {
|
|
// If a channel stop being autotranslated by the admin, delete the posts for that channel
|
|
// so the posts can be refetch without the translation
|
|
const newChannels = chData.channels || [];
|
|
const channels = await queryChannelsById(database, newChannels.map((c) => c.id)).fetch();
|
|
const chMap = new Map(channels.map((c) => [c.id, c]));
|
|
const promises = [];
|
|
for (const ch of newChannels) {
|
|
const channel = chMap.get(ch.id);
|
|
if (channel && channel.autotranslation && !ch.autotranslation) { // Autotranslation disabled
|
|
promises.push(deletePostsForChannel(serverUrl, ch.id, true));
|
|
}
|
|
}
|
|
const chModels = (await Promise.all(promises)).map((m) => m.models).flat();
|
|
if (chModels.length) {
|
|
await operator.batchRecords(chModels, 'handleAutotranslationChanges');
|
|
}
|
|
|
|
// If a channel starts or stops being autotranslated by the user, delete the posts for that channel
|
|
// so the posts can be refetch with or without the translation
|
|
const newMemberships = chData.memberships || [];
|
|
const memberships = await queryMyChannelsByChannelIds(database, newMemberships.map((m) => m.channel_id)).fetch();
|
|
const membershipMap = new Map(memberships.map((m) => [m.id, m]));
|
|
for (const m of newMemberships) {
|
|
const membership = membershipMap.get(m.channel_id);
|
|
if (membership && membership.autotranslationDisabled !== Boolean(m.autotranslation_disabled)) { // Autotranslation modified
|
|
promises.push(deletePostsForChannel(serverUrl, m.channel_id, true));
|
|
}
|
|
}
|
|
const membershipModels = (await Promise.all(promises)).map((m) => m.models).flat();
|
|
if (membershipModels.length) {
|
|
await operator.batchRecords(membershipModels, 'handleAutotranslationChanges');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logError('handleAutotranslationChanges', getFullErrorMessage(error));
|
|
}
|
|
}
|
|
|
|
export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
|
|
const membershipIds = new Set(memberships?.map((m) => m.channel_id));
|
|
const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id));
|
|
|
|
// If team and channel are the requested, return the channel
|
|
if (initialTeamId === requestedTeamId && requestedChannel) {
|
|
return requestedChannelId;
|
|
}
|
|
|
|
// DM or GMs don't care about changes in teams, so return directly
|
|
if (requestedChannel && isDMorGM(requestedChannel)) {
|
|
return requestedChannelId;
|
|
}
|
|
|
|
// Check if we are still members of any channel on the history
|
|
const teamChannelHistory = await getTeamChannelHistory(database, initialTeamId);
|
|
for (const c of teamChannelHistory) {
|
|
if (membershipIds.has(c) || c === Screens.GLOBAL_THREADS || c === Screens.GLOBAL_DRAFTS) {
|
|
return c;
|
|
}
|
|
}
|
|
|
|
// Check if we are member of the default channel.
|
|
const defaultChannel = channels?.find((c) => isDefaultChannel(c) && c.team_id === initialTeamId);
|
|
const iAmMemberOfTheTeamDefaultChannel = Boolean(defaultChannel && membershipIds.has(defaultChannel.id));
|
|
if (iAmMemberOfTheTeamDefaultChannel) {
|
|
return defaultChannel!.id;
|
|
}
|
|
|
|
// Get the first channel of the list, based on the locale.
|
|
const myFirstTeamChannel = channels?.filter((c) =>
|
|
c.team_id === requestedTeamId &&
|
|
c.type === General.OPEN_CHANNEL &&
|
|
membershipIds.has(c.id),
|
|
).sort(sortChannelsByDisplayName.bind(null, locale))[0];
|
|
return myFirstTeamChannel?.id || '';
|
|
}
|
|
|
|
export const setExtraSessionProps = async (serverUrl: string, groupLabel?: RequestGroupLabel) => {
|
|
try {
|
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const serverVersion = await getConfigValue(database, 'Version');
|
|
const deviceToken = await getDeviceToken();
|
|
const voipDeviceToken = await getVoIPDeviceToken();
|
|
|
|
// For new servers, we want to send all the information.
|
|
// For old servers, we only want to send the information when there
|
|
// is a device token. Sending the rest of the information should not
|
|
// create any issue.
|
|
if (isMinimumServerVersion(serverVersion, 10, 1, 0) || deviceToken) {
|
|
const res = await checkNotifications();
|
|
const granted = res.status === RESULTS.GRANTED || res.status === RESULTS.LIMITED;
|
|
const client = NetworkManager.getClient(serverUrl);
|
|
client.setExtraSessionProps(deviceToken, !granted, nativeApplicationVersion, voipDeviceToken || undefined, groupLabel);
|
|
}
|
|
return {};
|
|
} catch (error) {
|
|
logDebug('error on setExtraSessionProps', getFullErrorMessage(error));
|
|
return {error};
|
|
}
|
|
};
|
|
|
|
export async function verifyPushProxy(serverUrl: string, groupLabel?: RequestGroupLabel) {
|
|
const deviceId = await getDeviceToken();
|
|
if (!deviceId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const ppVerification = await getPushVerificationStatus(database);
|
|
if (
|
|
ppVerification !== PUSH_PROXY_STATUS_UNKNOWN &&
|
|
ppVerification !== ''
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const client = NetworkManager.getClient(serverUrl);
|
|
const response = await client.ping(deviceId, undefined, groupLabel);
|
|
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 (error) {
|
|
logDebug('error on verifyPushProxy', getFullErrorMessage(error));
|
|
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
export async function handleEntryAfterLoadNavigation(
|
|
serverUrl: string,
|
|
teamMembers: TeamMembership[],
|
|
channelMembers: ChannelMember[],
|
|
currentTeamId: string,
|
|
currentChannelId: string,
|
|
initialTeamId: string,
|
|
initialChannelId: string,
|
|
gmConverted: boolean,
|
|
) {
|
|
try {
|
|
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const currentTeamIdAfterLoad = await getCurrentTeamId(database);
|
|
const currentChannelIdAfterLoad = await getCurrentChannelId(database);
|
|
const mountedScreens = NavigationStore.getScreensInStack();
|
|
const isChannelScreenMounted = mountedScreens.includes(Screens.CHANNEL);
|
|
const isThreadsMounted = mountedScreens.includes(Screens.THREAD);
|
|
const tabletDevice = isTablet();
|
|
|
|
if (!currentTeamIdAfterLoad) {
|
|
// First load or no team
|
|
if (tabletDevice) {
|
|
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
|
|
} else {
|
|
await setCurrentTeamAndChannelId(operator, initialTeamId, '');
|
|
}
|
|
} else if (currentTeamIdAfterLoad !== currentTeamId) {
|
|
// Switched teams while loading
|
|
if (!teamMembers.find((t) => t.team_id === currentTeamIdAfterLoad && t.delete_at === 0)) {
|
|
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
|
|
}
|
|
} else if (currentTeamIdAfterLoad !== initialTeamId) {
|
|
if (gmConverted) {
|
|
await setCurrentTeamAndChannelId(operator, initialTeamId, currentChannelId);
|
|
} else {
|
|
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
|
|
}
|
|
} else if (currentChannelIdAfterLoad !== currentChannelId) {
|
|
// Switched channels while loading
|
|
if (!channelMembers.find((m) => m.channel_id === currentChannelIdAfterLoad)) {
|
|
if (tabletDevice || isChannelScreenMounted || isThreadsMounted) {
|
|
await handleKickFromChannel(serverUrl, currentChannelIdAfterLoad);
|
|
} else {
|
|
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
|
|
}
|
|
}
|
|
} else if (currentChannelIdAfterLoad && currentChannelIdAfterLoad !== initialChannelId) {
|
|
if (tabletDevice || isChannelScreenMounted || isThreadsMounted) {
|
|
await handleKickFromChannel(serverUrl, currentChannelIdAfterLoad);
|
|
} else {
|
|
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logDebug('could not manage the entry after load navigation', error);
|
|
}
|
|
}
|