diff --git a/app/actions/remote/entry.ts b/app/actions/remote/entry.ts index d704cd9134..cd059ccd9e 100644 --- a/app/actions/remote/entry.ts +++ b/app/actions/remote/entry.ts @@ -8,22 +8,26 @@ import {General, Preferences} from '@constants'; 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 '@init/network_manager'; -import {prepareMyChannelsForTeam} from '@queries/servers/channel'; -import {prepareMyPreferences} from '@queries/servers/preference'; -import {prepareCommonSystemValues} from '@queries/servers/system'; -import {addChannelToTeamHistory, prepareMyTeams} from '@queries/servers/team'; +import {queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel'; +import {prepareModels} from '@queries/servers/entry'; +import {prepareCommonSystemValues, queryCommonSystemValues, queryConfig, queryCurrentTeamId, queryWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {addChannelToTeamHistory, deleteMyTeams, queryAvailableTeamIds, queryMyTeams, queryTeamsById} from '@queries/servers/team'; +import {queryCurrentUser} from '@queries/servers/user'; import {selectDefaultChannelForTeam} from '@utils/channel'; import {fetchMissingSidebarInfo, fetchMyChannelsForTeam, MyChannelsRequest} from './channel'; import {fetchGroupsForTeam} from './group'; import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post'; import {MyPreferencesRequest, fetchMyPreferences} from './preference'; -import {fetchRolesIfNeeded} from './role'; +import {fetchRoles, fetchRolesIfNeeded} from './role'; import {ConfigAndLicenseRequest, fetchConfigAndLicense} from './systems'; import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from './team'; +import {fetchMe, MyUserRequest} from './user'; import type {Client} from '@client/rest'; +import type ClientError from '@client/rest/error'; type AfterLoginArgs = { serverUrl: string; @@ -31,10 +35,77 @@ type AfterLoginArgs = { deviceToken?: string; } +type AppEntryData = { + initialTeamId: string; + teamData: MyTeamsRequest; + chData?: MyChannelsRequest; + prefData: MyPreferencesRequest; + meData: MyUserRequest; + removeTeamIds?: string[]; + removeChannelIds?: string[]; +} + +type AppEntryError = { + error?: Error | ClientError | string; +} + +export const appEntry = async (serverUrl: string) => { + const dt = Date.now(); + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + const {database} = operator; + + const currentTeamId = await queryCurrentTeamId(database); + const fetchedData = await fetchAppEntryData(serverUrl, currentTeamId); + const fetchedError = (fetchedData as AppEntryError).error; + + if (fetchedError) { + return {error: fetchedError, time: Date.now() - dt}; + } + + const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData; + + if (initialTeamId !== currentTeamId) { + // Immediately set the new team as the current team in the database so that the UI + // renders the correct team. + setCurrentTeamAndChannelId(operator, initialTeamId, ''); + } + + let removeTeams; + if (removeTeamIds?.length) { + // Immediately delete myTeams so that the UI renders only teams the user is a member of. + removeTeams = await queryTeamsById(database, removeTeamIds); + await deleteMyTeams(operator, removeTeams!); + } + + fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user); + + let removeChannels; + if (removeChannelIds?.length) { + removeChannels = await queryChannelsById(database, removeChannelIds); + } + + const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData}); + const models = await Promise.all(modelPromises); + if (models.length) { + await operator.batchRecords(models.flat() as Model[]); + } + + const {id: currentUserId, locale: currentUserLocale} = meData.user || (await queryCurrentUser(database))!; + const {config, license} = await queryCommonSystemValues(database); + deferredAppEntryActions(serverUrl, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId); + + const error = teamData.error || chData?.error || prefData.error || meData.error; + return {error, time: Date.now() - dt}; +}; + export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) => { const dt = Date.now(); - const database = DatabaseManager.serverDatabases[serverUrl]?.database; - if (!database) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { return {error: `${serverUrl} database not found`}; } @@ -111,29 +182,7 @@ export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) } } - const modelPromises: Array> = []; - const {operator} = DatabaseManager.serverDatabases[serverUrl]; - - if (prefData.preferences) { - const prefModel = prepareMyPreferences(operator, prefData.preferences!); - if (prefModel) { - modelPromises.push(prefModel); - } - } - - if (teamData.teams) { - const teamModels = prepareMyTeams(operator, teamData.teams!, teamData.memberships!, teamData.unreads!); - if (teamModels) { - modelPromises.push(...teamModels); - } - } - - if (chData?.channels?.length) { - const channelModels = await prepareMyChannelsForTeam(operator, initialTeam!.id, chData.channels, chData.memberships!); - if (channelModels) { - modelPromises.push(...channelModels); - } - } + const modelPromises = await prepareModels({operator, teamData, chData, prefData, initialTeamId: initialTeam?.id}); const systemModels = prepareCommonSystemValues( operator, @@ -167,7 +216,6 @@ export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) const error = clData.error || prefData.error || teamData.error || chData?.error; return {error, time: Date.now() - dt, hasTeams: Boolean((myTeams?.length || 0) > 0 && !teamData.error)}; } catch (error) { - const {operator} = DatabaseManager.serverDatabases[serverUrl]; const systemModels = await prepareCommonSystemValues(operator, { config: ({} as ClientConfig), license: ({} as ClientLicense), @@ -182,6 +230,145 @@ export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) } }; +const fetchAppEntryData = async (serverUrl: string, initialTeamId: string): Promise => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + const lastDisconnected = await queryWebSocketLastDisconnected(database); + const includeDeletedChannels = true; + const fetchOnly = true; + + // Fetch in parallel teams / team membership / team unreads / channels for current team / user preferences / user + const promises: [Promise, Promise, Promise, Promise] = [ + fetchMyTeams(serverUrl, fetchOnly), + initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, lastDisconnected, fetchOnly) : Promise.resolve(undefined), + fetchMyPreferences(serverUrl, fetchOnly), + fetchMe(serverUrl, fetchOnly), + ]; + + const resolution = await Promise.all(promises); + const [teamData, , prefData, 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 queryConfig(database); + const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string; + const teamMembers = teamData.memberships.map((m) => m.team_id); + const myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id)); + const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, config.ExperimentalPrimaryTeam); + if (defaultTeam?.id) { + chData = await fetchMyChannelsForTeam(serverUrl, defaultTeam.id, includeDeletedChannels, lastDisconnected, fetchOnly); + } + } + + let data: AppEntryData = { + initialTeamId, + teamData, + chData, + prefData, + meData, + }; + + if (teamData.teams?.length === 0) { + // User is no longer a member of any team + const myTeams = await queryMyTeams(database); + const removeTeamIds: string[] = 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 || chError?.status_code === 403) { + // User is no longer a member of the current team + const removeTeamIds = [initialTeamId]; + + const availableTeamIds = await queryAvailableTeamIds(database, initialTeamId, teamData.teams, prefData.preferences, meData.user?.locale); + const alternateTeamData = await fetchAlternateTeamData(serverUrl, availableTeamIds, removeTeamIds, includeDeletedChannels, lastDisconnected, fetchOnly); + + data = { + ...data, + ...alternateTeamData, + }; + } + + if (data.chData?.channels) { + const removeChannelIds: string[] = []; + const fetchedChannelIds = data.chData.channels.map((channel) => channel.id); + + const channels = await queryAllChannelsForTeam(database, initialTeamId); + for (const channel of channels) { + if (!fetchedChannelIds.includes(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) => { + 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); + 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}; +}; + +const deferredAppEntryActions = async ( + serverUrl: string, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined, config: ClientConfig, license: ClientLicense, teamData: MyTeamsRequest, + chData: MyChannelsRequest | undefined, initialTeamId: string) => { + // defer sidebar DM & GM profiles + if (chData?.channels?.length && chData.memberships?.length) { + const directChannels = chData.channels.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL); + const channelsToFetchProfiles = new Set(directChannels); + if (channelsToFetchProfiles.size) { + const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license); + await fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId); + } + + // defer fetching posts for unread channels on initial team + fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships); + } + + // defer groups for team + if (initialTeamId) { + await fetchGroupsForTeam(serverUrl, initialTeamId); + } + + // defer fetch channels and unread posts for other teams + if (teamData.teams?.length && teamData.memberships?.length) { + fetchTeamsChannelsAndUnreadPosts(serverUrl, teamData.teams, teamData.memberships, initialTeamId); + } +}; + const deferredLoginActions = async ( serverUrl: string, user: UserProfile, prefData: MyPreferencesRequest, clData: ConfigAndLicenseRequest, teamData: MyTeamsRequest, chData?: MyChannelsRequest, initialTeam?: Team, initialChannel?: Channel) => { diff --git a/app/actions/remote/role.ts b/app/actions/remote/role.ts index a1221e31c8..e695f6c3ac 100644 --- a/app/actions/remote/role.ts +++ b/app/actions/remote/role.ts @@ -56,3 +56,30 @@ export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string return {error}; } }; + +export const fetchRoles = async (serverUrl: string, teamMembership?: TeamMembership[], channelMembership?: ChannelMembership[], user?: UserProfile) => { + const rolesToFetch = new Set(user?.roles.split(' ') || []); + + if (teamMembership?.length) { + const teamRoles: string[] = []; + const teamMembers: string[] = []; + + teamMembership?.forEach((tm) => { + teamRoles.push(...tm.roles.split(' ')); + teamMembers.push(tm.team_id); + }); + + teamRoles.forEach(rolesToFetch.add, rolesToFetch); + } + + if (channelMembership?.length) { + for (let i = 0; i < channelMembership!.length; i++) { + const member = channelMembership[i]; + member.roles.split(' ').forEach(rolesToFetch.add, rolesToFetch); + } + } + + if (rolesToFetch.size > 0) { + fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch)); + } +}; diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 065cd8cb95..45ab60ff62 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -20,6 +20,11 @@ import type {LoadMeArgs} from '@typings/database/database'; import type RoleModel from '@typings/database/models/servers/role'; import type UserModel from '@typings/database/models/servers/user'; +export type MyUserRequest = { + user?: UserProfile; + error?: unknown; +} + export type ProfilesPerChannelRequest = { data?: ProfilesInChannelRequest[]; error?: unknown; @@ -31,6 +36,31 @@ export type ProfilesInChannelRequest = { error?: unknown; } +export const fetchMe = async (serverUrl: string, fetchOnly = false): Promise => { + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const user = await client.getMe(); + + if (!fetchOnly) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (operator) { + operator.handleUsers({users: [user], prepareRecordsOnly: false}); + } + } + + return {user}; + } catch (error) { + await forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + export const fetchProfilesInChannel = async (serverUrl: string, channelId: string, excludeUserId?: string, fetchOnly = false): Promise => { let client: Client; try { diff --git a/app/components/post_list/post/header/index.ts b/app/components/post_list/post/header/index.ts index 14787776b5..d87d8679f7 100644 --- a/app/components/post_list/post/header/index.ts +++ b/app/components/post_list/post/header/index.ts @@ -21,6 +21,7 @@ import type SystemModel from '@typings/database/models/servers/system'; type HeaderInputProps = { config: ClientConfig; + differentThreadSequence: boolean; license: ClientLicense; preferences: PreferenceModel[]; post: PostModel; @@ -33,8 +34,8 @@ const withBaseHeaderProps = withObservables([], ({database}: WithDatabaseArgs & })); const withHeaderProps = withObservables( - ['preferences', 'post'], - ({config, post, license, database, preferences}: WithDatabaseArgs & HeaderInputProps) => { + ['preferences', 'post', 'differentThreadSequence'], + ({config, post, license, database, preferences, differentThreadSequence}: WithDatabaseArgs & HeaderInputProps) => { const author = post.author.observe(); const enablePostUsernameOverride = of$(config.EnablePostUsernameOverride === 'true'); const isTimezoneEnabled = of$(config.ExperimentalTimezone === 'true'); @@ -47,13 +48,13 @@ const withHeaderProps = withObservables( Q.where('delete_at', Q.eq(0)), ), ).observeCount(); - const rootPostAuthor = post.root.observe().pipe(switchMap((root) => { + const rootPostAuthor = differentThreadSequence ? post.root.observe().pipe(switchMap((root) => { if (root.length) { return root[0].author.observe(); } return of$(null); - })); + })) : of$(null); return { author, diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 9cff702dc4..a97bcb7565 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -40,7 +40,7 @@ async function shouldHighlightReplyBar(currentUser: UserModel, post: PostModel, let rootPost: PostModel | undefined; const myPosts = await postsInThread.collections.get(POST).query( Q.and( - Q.where('root_id', post.id || post.rootId), + Q.where('root_id', post.rootId || post.id), Q.where('create_at', Q.between(postsInThread.earliest, postsInThread.latest)), Q.where('user_id', currentUser.id), ), @@ -59,8 +59,8 @@ async function shouldHighlightReplyBar(currentUser: UserModel, post: PostModel, commentsNotifyLevel = currentUser.notifyProps.comments; } - const fromCurrentUser = post.userId !== currentUser.id || Boolean(post.props?.from_webhook); - if (!fromCurrentUser) { + const notCurrentUser = post.userId !== currentUser.id || Boolean(post.props?.from_webhook); + if (notCurrentUser) { if (commentsNotifyLevel === Preferences.COMMENTS_ANY && (threadCreatedByCurrentUser || threadRepliedToByCurrentUser)) { return true; } else if (commentsNotifyLevel === Preferences.COMMENTS_ROOT && threadCreatedByCurrentUser) { @@ -109,8 +109,9 @@ const withPost = withObservables( return of$(false); })); + let differentThreadSequence = true; if (post.rootId) { - const differentThreadSequence = previousPost?.rootId ? previousPost?.rootId !== post.rootId : previousPost?.id !== post.rootId; + differentThreadSequence = previousPost?.rootId ? previousPost?.rootId !== post.rootId : previousPost?.id !== post.rootId; isFirstReply = of$(differentThreadSequence || (previousPost?.id === post.rootId || previousPost?.rootId === post.rootId)); isLastReply = of$(!(nextPost?.rootId === post.rootId)); } @@ -130,6 +131,7 @@ const withPost = withObservables( appsEnabled: of$(appsEnabled(partialConfig)), canDelete, currentUser, + differentThreadSequence: of$(differentThreadSequence), files: post.files.observe(), highlightReplyBar, isConsecutivePost, diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 8ac4d703e2..8c55fae2af 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -32,6 +32,7 @@ type PostProps = { appsEnabled: boolean; canDelete: boolean; currentUser: UserModel; + differentThreadSequence: boolean; files: FileModel[]; highlight?: boolean; highlightPinnedOrFlagged?: boolean; @@ -95,7 +96,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const Post = ({ - appsEnabled, canDelete, currentUser, files, highlight, highlightPinnedOrFlagged = true, highlightReplyBar, + appsEnabled, canDelete, currentUser, differentThreadSequence, files, highlight, highlightPinnedOrFlagged = true, highlightReplyBar, isConsecutivePost, isEphemeral, isFirstReply, isFlagged, isJumboEmoji, isLastReply, isPostAddChannelMember, location, post, reactionsCount, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, style, testID, @@ -207,6 +208,7 @@ const Post = ({ header = (
{ if (database) { EphemeralStore.theme = await queryThemeForCurrentTeam(database); } - launchToChannel({...props, serverUrl}, resetNavigation); + + launchToHome({...props, serverUrl}, resetNavigation); + return; } } @@ -77,8 +80,20 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { launchToServer(props, resetNavigation); }; -const launchToChannel = (props: LaunchProps, resetNavigation: Boolean) => { - // TODO: Use LaunchProps to fetch posts for channel and then load user profile, etc... +const launchToHome = (props: LaunchProps, resetNavigation: Boolean) => { + switch (props.launchType) { + case LaunchType.DeepLink: + // TODO: + // deepLinkEntry({props.serverUrl, props.extra}); + break; + case LaunchType.Notification: { + // TODO: + // pushNotificationEntry({props.serverUrl, props.extra}) + break; + } + default: + appEntry(props.serverUrl!); + } const passProps = { skipMetrics: true, @@ -87,8 +102,8 @@ const launchToChannel = (props: LaunchProps, resetNavigation: Boolean) => { if (resetNavigation) { // eslint-disable-next-line no-console - console.log('Launch app in Channel screen'); - resetToChannel(passProps); + console.log('Launch app in Home screen'); + resetToHome(passProps); return; } diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 09bc10cd95..5945428f16 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -1,14 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; +import {prepareDeletePost} from './post'; + import type ServerDataOperator from '@database/operator/server_data_operator'; import type ChannelModel from '@typings/database/models/servers/channel'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type PostModel from '@typings/database/models/servers/post'; const {SERVER: {CHANNEL, MY_CHANNEL}} = MM_TABLES; @@ -53,6 +56,41 @@ export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, tea } }; +export const prepareDeleteChannel = async (channel: ChannelModel): Promise => { + const preparedModels: Model[] = [channel.prepareDestroyPermanently()]; + + const relations: Array> = [channel.membership, channel.info, channel.settings]; + for await (const relation of relations) { + try { + const model = await relation.fetch(); + if (model) { + preparedModels.push(model.prepareDestroyPermanently()); + } + } catch { + // Record not found, do nothing + } + } + + const associatedChildren: Array> = [ + channel.members, + channel.drafts, + channel.groupsChannel, + channel.postsInChannel, + ]; + for await (const children of associatedChildren) { + const models = await children.fetch() as Model[]; + models.forEach((model) => preparedModels.push(model.prepareDestroyPermanently())); + } + + const posts = await channel.posts.fetch() as PostModel[]; + for await (const post of posts) { + const preparedPost = await prepareDeletePost(post); + preparedModels.push(...preparedPost); + } + + return preparedModels; +}; + export const queryAllChannelsForTeam = (database: Database, teamId: string) => { return database.get(CHANNEL).query(Q.where('team_id', teamId)).fetch() as Promise; }; @@ -78,3 +116,12 @@ export const queryChannelByName = async (database: Database, channelName: string return undefined; } }; + +export const queryChannelsById = async (database: Database, channelIds: string[]): Promise => { + try { + const channels = (await database.get(CHANNEL).query(Q.where('id', Q.oneOf(channelIds))).fetch()) as ChannelModel[]; + return channels; + } catch { + return undefined; + } +}; diff --git a/app/queries/servers/entry.ts b/app/queries/servers/entry.ts new file mode 100644 index 0000000000..04da8f6d03 --- /dev/null +++ b/app/queries/servers/entry.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import ServerDataOperator from '@database/operator/server_data_operator'; + +import {prepareDeleteChannel, prepareMyChannelsForTeam} from './channel'; +import {prepareMyPreferences} from './preference'; +import {prepareDeleteTeam, prepareMyTeams} from './team'; +import {prepareUsers} from './user'; + +import type {MyChannelsRequest} from '@actions/remote/channel'; +import type {MyPreferencesRequest} from '@actions/remote/preference'; +import type {MyTeamsRequest} from '@actions/remote/team'; +import type {MyUserRequest} from '@actions/remote/user'; +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; + meData?: MyUserRequest; +} + +export const prepareModels = async ({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData}: PrepareModelsArgs): Promise>> => { + const modelPromises: Array> = []; + + if (removeTeams?.length) { + removeTeams.forEach((team) => { + modelPromises.push(prepareDeleteTeam(team)); + }); + } + + if (removeChannels?.length) { + removeChannels.forEach((channel) => { + modelPromises.push(prepareDeleteChannel(channel)); + }); + } + + if (teamData?.teams?.length) { + const teamModels = prepareMyTeams(operator, teamData.teams, teamData.memberships || [], teamData.unreads || []); + if (teamModels) { + modelPromises.push(...teamModels); + } + } + + if (initialTeamId && chData?.channels?.length) { + const channelModels = await prepareMyChannelsForTeam(operator, initialTeamId, chData.channels, chData.memberships || []); + if (channelModels) { + modelPromises.push(...channelModels); + } + } + + if (prefData?.preferences?.length) { + const prefModel = prepareMyPreferences(operator, prefData.preferences); + if (prefModel) { + modelPromises.push(prefModel); + } + } + + if (meData?.user) { + const userModels = prepareUsers(operator, [meData.user]); + if (userModels) { + modelPromises.push(userModels); + } + } + + return modelPromises; +}; diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index cd9e9e6a10..331a042e7c 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; @@ -10,6 +10,33 @@ import type PostInChannelModel from '@typings/database/models/servers/posts_in_c const {SERVER: {POST, POSTS_IN_CHANNEL}} = MM_TABLES; +export const prepareDeletePost = async (post: PostModel): Promise => { + const preparedModels: Model[] = [post.prepareDestroyPermanently()]; + const relations: Array | Query> = [post.drafts, post.postsInThread]; + for await (const relation of relations) { + try { + const model = await relation.fetch(); + if (model) { + if (Array.isArray(model)) { + model.forEach((m) => preparedModels.push(m.prepareDestroyPermanently())); + } else { + preparedModels.push(model.prepareDestroyPermanently()); + } + } + } catch { + // Record not found, do nothing + } + } + + const associatedChildren: Array> = [post.files, post.reactions]; + for await (const children of associatedChildren) { + const models = await children.fetch() as Model[]; + models.forEach((model) => preparedModels.push(model.prepareDestroyPermanently())); + } + + return preparedModels; +}; + export const queryPostById = async (database: Database, postId: string) => { try { const userRecord = (await database.collections.get(MM_TABLES.SERVER.POST).find(postId)) as PostModel; diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index e2ded0d23e..fb123de508 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -81,6 +81,15 @@ export const queryCommonSystemValues = async (serverDatabase: Database) => { }; }; +export const queryConfig = async (serverDatabase: Database) => { + try { + const config = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.CONFIG) as SystemModel; + return (config?.value || {}) as ClientConfig; + } catch { + return {} as ClientConfig; + } +}; + export const queryExpandedLinks = async (serverDatabase: Database) => { try { const expandedLinks = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.EXPANDED_LINKS) as SystemModel; diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index a35be7c18a..6da6d8c067 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -1,11 +1,19 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb'; -import {Database as DatabaseConstants} from '@constants'; +import {Database as DatabaseConstants, Preferences} from '@constants'; +import {getPreferenceValue} from '@helpers/api/preference'; +import {selectDefaultTeam} from '@helpers/api/team'; + +import {prepareDeleteChannel} from './channel'; +import {queryPreferencesByCategoryAndName} from './preference'; +import {queryConfig} from './system'; +import {queryCurrentUser} from './user'; import type ServerDataOperator from '@database/operator/server_data_operator'; +import type ChannelModel from '@typings/database/models/servers/channel'; import type MyTeamModel from '@typings/database/models/servers/my_team'; import type TeamModel from '@typings/database/models/servers/team'; import type TeamChannelHistoryModel from '@typings/database/models/servers/team_channel_history'; @@ -58,6 +66,51 @@ export const prepareMyTeams = (operator: ServerDataOperator, teams: Team[], memb } }; +export const deleteMyTeams = async (operator: ServerDataOperator, teams: TeamModel[]) => { + const preparedModels: Model[] = []; + for await (const team of teams) { + const myTeam = await team.myTeam.fetch() as MyTeamModel; + preparedModels.push(myTeam.prepareDestroyPermanently()); + } + + await operator.batchRecords(preparedModels); +}; + +export const prepareDeleteTeam = async (team: TeamModel): Promise => { + const preparedModels: Model[] = [team.prepareDestroyPermanently()]; + + const relations: Array> = [team.myTeam, team.teamChannelHistory]; + for await (const relation of relations) { + try { + const model = await relation.fetch(); + if (model) { + preparedModels.push(model.prepareDestroyPermanently()); + } + } catch { + // Record not found, do nothing + } + } + + const associatedChildren: Array> = [ + team.members, + team.groupsTeam, + team.slashCommands, + team.teamSearchHistories, + ]; + for await (const children of associatedChildren) { + const models = await children.fetch() as Model[]; + models.forEach((model) => preparedModels.push(model.prepareDestroyPermanently())); + } + + const channels = await team.channels.fetch() as ChannelModel[]; + for await (const channel of channels) { + const preparedChannel = await prepareDeleteChannel(channel); + preparedModels.push(...preparedChannel); + } + + return preparedModels; +}; + export const queryMyTeamById = async (database: Database, teamId: string): Promise => { try { const myTeam = (await database.get(MY_TEAM).find(teamId)) as MyTeamModel; @@ -76,6 +129,15 @@ export const queryTeamById = async (database: Database, teamId: string): Promise } }; +export const queryTeamsById = async (database: Database, teamIds: string[]): Promise => { + try { + const teams = (await database.get(TEAM).query(Q.where('id', Q.oneOf(teamIds))).fetch()) as TeamModel[]; + return teams; + } catch { + return undefined; + } +}; + export const queryTeamByName = async (database: Database, teamName: string): Promise => { try { const team = (await database.get(TEAM).query(Q.where('name', teamName)).fetch()) as TeamModel[]; @@ -88,3 +150,39 @@ export const queryTeamByName = async (database: Database, teamName: string): Pro return undefined; } }; + +export const queryMyTeams = async (database: Database): Promise => { + try { + const teams = (await database.get(MY_TEAM).query().fetch()) as MyTeamModel[]; + return teams; + } catch { + return undefined; + } +}; + +export const queryAvailableTeamIds = async (database: Database, excludeTeamId: string, teams?: Team[], preferences?: PreferenceType[], locale?: string): Promise => { + let availableTeamIds: string[] = []; + + if (teams) { + let teamOrderPreference; + if (preferences) { + teamOrderPreference = getPreferenceValue(preferences, Preferences.TEAMS_ORDER, '', '') as string; + } else { + const dbPreferences = await queryPreferencesByCategoryAndName(database, Preferences.TEAMS_ORDER, ''); + teamOrderPreference = dbPreferences[0].value; + } + + const userLocale = locale || (await queryCurrentUser(database))?.locale; + const config = await queryConfig(database); + const defaultTeam = selectDefaultTeam(teams, userLocale, teamOrderPreference, config.ExperimentalPrimaryTeam); + + availableTeamIds = [defaultTeam!.id]; + } else { + const dbTeams = await queryMyTeams(database); + if (dbTeams) { + availableTeamIds = dbTeams.map((team) => team.id); + } + } + + return availableTeamIds.filter((id) => id !== excludeTeamId); +}; diff --git a/app/screens/home/channel_list/index.tsx b/app/screens/home/channel_list/index.tsx index ba9135018b..05c0abecfa 100644 --- a/app/screens/home/channel_list/index.tsx +++ b/app/screens/home/channel_list/index.tsx @@ -81,7 +81,7 @@ const ChannelListScreen = (props: ChannelProps) => { goToScreen('Channel', '', undefined, {topBar: {visible: false}})} - style={{fontSize: 20, color: '#fff'}} + style={{fontSize: 20, color: theme.centerChannelColor}} > {'Channel List'} diff --git a/app/screens/login/index.tsx b/app/screens/login/index.tsx index 6afbce56e6..0dda020314 100644 --- a/app/screens/login/index.tsx +++ b/app/screens/login/index.tsx @@ -27,7 +27,7 @@ import ErrorText from '@components/error_text'; import FormattedText from '@components/formatted_text'; import {FORGOT_PASSWORD, MFA} from '@constants/screens'; import {t} from '@i18n'; -import {goToScreen, resetToChannel} from '@screens/navigation'; +import {goToScreen, resetToHome} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -136,13 +136,13 @@ const Login: NavigationFunctionComponent = ({config, extra, launchError, launchT console.log('GO TO NO TEAMS'); return; } - await goToChannel(result.time || 0, result.error as never); + goToHome(result.time || 0, result.error as never); } }; - const goToChannel = async (time: number, loginError?: never) => { + const goToHome = (time: number, loginError?: never) => { const hasError = launchError || Boolean(loginError); - resetToChannel({extra, launchError: hasError, launchType, serverUrl, time}); + resetToHome({extra, launchError: hasError, launchType, serverUrl, time}); }; const checkLoginResponse = (data: LoginActionResponse) => { @@ -172,7 +172,7 @@ const Login: NavigationFunctionComponent = ({config, extra, launchError, launchT const goToMfa = () => { const screen = MFA; const title = intl.formatMessage({id: 'mobile.routes.mfa', defaultMessage: 'Multi-factor Authentication'}); - goToScreen(screen, title, {goToChannel, loginId, password, config, license, serverUrl, theme}); + goToScreen(screen, title, {goToHome, loginId, password, config, license, serverUrl, theme}); }; const getLoginErrorMessage = (loginError: string | ClientErrorProps | Error) => { diff --git a/app/screens/login/login.test.tsx b/app/screens/login/login.test.tsx index 7c69f6f80c..16681767b6 100644 --- a/app/screens/login/login.test.tsx +++ b/app/screens/login/login.test.tsx @@ -115,7 +115,7 @@ describe('Login', () => { 'MFA', 'Multi-factor Authentication', { - goToChannel: expect.anything(), + goToHome: expect.anything(), loginId, password, config: {EnableSignInWithEmail: 'true', EnableSignInWithUsername: 'true'}, diff --git a/app/screens/mfa/index.tsx b/app/screens/mfa/index.tsx index 721ef935d9..3244e7b080 100644 --- a/app/screens/mfa/index.tsx +++ b/app/screens/mfa/index.tsx @@ -27,7 +27,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; type MFAProps = { config: Partial; - goToChannel: (time: number, error?: never) => void; + goToHome: (time: number, error?: never) => void; license: Partial; loginId: string; password: string; @@ -35,7 +35,7 @@ type MFAProps = { theme: Theme; } -const MFA = ({config, goToChannel, license, loginId, password, serverUrl, theme}: MFAProps) => { +const MFA = ({config, goToHome, license, loginId, password, serverUrl, theme}: MFAProps) => { const intl = useIntl(); const [token, setToken] = useState(''); const [error, setError] = useState(''); @@ -98,7 +98,7 @@ const MFA = ({config, goToChannel, license, loginId, password, serverUrl, theme} console.log('GO TO NO TEAMS'); return; } - goToChannel(result.time || 0, result.error as never); + goToHome(result.time || 0, result.error as never); }); const getProceedView = () => { diff --git a/app/screens/mfa/mfa.test.tsx b/app/screens/mfa/mfa.test.tsx index 8e1fa20d00..172978b2b6 100644 --- a/app/screens/mfa/mfa.test.tsx +++ b/app/screens/mfa/mfa.test.tsx @@ -18,7 +18,7 @@ jest.mock('@actions/remote/session', () => { describe('*** MFA Screen ***', () => { const baseProps = { config: {}, - goToChannel: jest.fn(), + goToHome: jest.fn(), loginId: 'loginId', password: 'passwd', license: {}, @@ -34,10 +34,10 @@ describe('*** MFA Screen ***', () => { test('should call login method on submit', async () => { const props = { ...baseProps, - goToChannel: jest.fn(), + goToHome: jest.fn(), }; - const spyOnGoToChannel = jest.spyOn(props, 'goToChannel'); + const spyOnGoToHome = jest.spyOn(props, 'goToHome'); const {getByTestId} = renderWithIntl(); const submitBtn = getByTestId('login_mfa.submit'); const inputText = getByTestId('login_mfa.input'); @@ -47,6 +47,6 @@ describe('*** MFA Screen ***', () => { fireEvent.press(submitBtn); }); - expect(spyOnGoToChannel).toHaveBeenCalled(); + expect(spyOnGoToHome).toHaveBeenCalled(); }); }); diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 83698c9375..a208194af6 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -29,7 +29,7 @@ function getThemeFromState() { return Preferences.THEMES.denim; } -export function resetToChannel(passProps = {}) { +export function resetToHome(passProps = {}) { const theme = getThemeFromState(); EphemeralStore.clearNavigationComponents(); diff --git a/app/screens/sso/index.tsx b/app/screens/sso/index.tsx index b7667083ce..2cfd54c178 100644 --- a/app/screens/sso/index.tsx +++ b/app/screens/sso/index.tsx @@ -7,7 +7,7 @@ import React, {useState} from 'react'; import {ssoLogin} from '@actions/remote/session'; import ClientError from '@client/rest/error'; import {SSO as SSOEnum} from '@constants'; -import {resetToChannel} from '@screens/navigation'; +import {resetToHome} from '@screens/navigation'; import {isMinimumServerVersion} from '@utils/helpers'; import SSOWithRedirectURL from './sso_with_redirect_url'; @@ -83,12 +83,12 @@ const SSO = ({config, extra, launchError, launchType, serverUrl, ssoType, theme} console.log('GO TO NO TEAMS'); return; } - goToChannel(result.time || 0, result.error as never); + goToHome(result.time || 0, result.error as never); }; - const goToChannel = (time: number, error?: never) => { + const goToHome = (time: number, error?: never) => { const hasError = launchError || Boolean(error); - resetToChannel({extra, launchError: hasError, launchType, serverUrl, time}); + resetToHome({extra, launchError: hasError, launchType, serverUrl, time}); }; const isSSOWithRedirectURLAvailable = isMinimumServerVersion(config.Version!, 5, 33, 0); diff --git a/ios/Gemfile b/ios/Gemfile index 7e39e818bf..e29331aa6d 100644 --- a/ios/Gemfile +++ b/ios/Gemfile @@ -1,3 +1,3 @@ source "https://rubygems.org" -gem "cocoapods", "1.10.1" +gem "cocoapods", "1.10.2" diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index 3602a15077..8e25005147 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -1,23 +1,24 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.3) - activesupport (5.2.5) + CFPropertyList (3.0.4) + rexml + activesupport (5.2.6) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - addressable (2.7.0) + addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) atomos (0.1.3) claide (1.0.3) - cocoapods (1.10.1) + cocoapods (1.10.2) addressable (~> 2.6) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.10.1) + cocoapods-core (= 1.10.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 1.4.0, < 2.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -32,7 +33,7 @@ GEM nap (~> 1.0) ruby-macho (~> 1.4) xcodeproj (>= 1.19.0, < 2.0) - cocoapods-core (1.10.1) + cocoapods-core (1.10.2) activesupport (> 5.0, < 6) addressable (~> 2.6) algoliasearch (~> 1.0) @@ -42,21 +43,21 @@ GEM netrc (~> 0.11) public_suffix typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.4) - cocoapods-downloader (1.4.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (1.5.1) cocoapods-plugins (1.0.0) nap - cocoapods-search (1.0.0) - cocoapods-trunk (1.5.0) + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) nap (>= 0.8, < 2.0) netrc (~> 0.11) cocoapods-try (1.2.0) colored2 (3.1.2) - concurrent-ruby (1.1.8) + concurrent-ruby (1.1.9) escape (0.0.4) - ethon (0.13.0) + ethon (0.14.0) ffi (>= 1.15.0) - ffi (1.15.0) + ffi (1.15.4) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) @@ -70,24 +71,26 @@ GEM nap (1.1.0) netrc (0.11.0) public_suffix (4.0.6) + rexml (3.2.5) ruby-macho (1.4.0) thread_safe (0.3.6) typhoeus (1.4.0) ethon (>= 0.9.0) tzinfo (1.2.9) thread_safe (~> 0.1) - xcodeproj (1.19.0) + xcodeproj (1.21.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) + rexml (~> 3.2.4) PLATFORMS ruby DEPENDENCIES - cocoapods (= 1.10.1) + cocoapods (= 1.10.2) BUNDLED WITH 2.1.4