From bae5477b3595e6bf760a978d26c6c9881d2080ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Fri, 29 Jul 2022 16:28:32 +0200 Subject: [PATCH] Graph QL POC (#6024) * First approach * Lint * Fixes and adding monitoring console statements (to be removed later) * Add pagination and apply graphQL also to login * Get all entry points to use the same GQL call * Unify gql handling * Use graphQL on websocket reconnect * Handle latest changes regarding categories * Use graphQL to properly fetch channel members on other servers * Remove logs and fetch unreads from other teams * Minor fixes * Final fixes * Address feedback, minor refactoring, and fixes around the refactor * Fix custom status duration types * Add missing fields and some reordering * Add timeout to fetch posts for unread channels --- app/actions/local/group.ts | 3 +- app/actions/remote/entry/app.ts | 37 +- app/actions/remote/entry/common.ts | 58 ++- app/actions/remote/entry/gql_common.ts | 311 +++++++++++++++ app/actions/remote/entry/login.ts | 97 +++-- app/actions/remote/entry/notification.ts | 66 ++-- app/actions/websocket/index.ts | 45 ++- app/client/graphQL/constants.ts | 16 + app/client/graphQL/entry.ts | 371 ++++++++++++++++++ .../custom_status_emoji.test.tsx | 4 +- app/components/profile_picture/image.tsx | 2 +- app/constants/custom_status.ts | 6 +- app/constants/graphql.ts | 4 + app/constants/index.ts | 2 - .../server_data_operator/transformers/user.ts | 4 +- app/queries/servers/channel.ts | 17 +- app/queries/servers/entry.ts | 10 +- .../custom_status/components/clear_after.tsx | 4 +- .../components/custom_status_suggestion.tsx | 8 +- .../components/custom_status_suggestions.tsx | 19 +- app/screens/custom_status/index.tsx | 43 +- .../components/clear_after_menu_item.tsx | 16 +- .../custom_status_clear_after/index.tsx | 12 +- app/utils/graphql.ts | 213 ++++++++++ app/utils/user/index.ts | 6 +- types/api/config.d.ts | 1 + types/api/graphql.d.ts | 183 +++++++++ types/api/users.d.ts | 8 +- 28 files changed, 1408 insertions(+), 158 deletions(-) create mode 100644 app/actions/remote/entry/gql_common.ts create mode 100644 app/client/graphQL/constants.ts create mode 100644 app/client/graphQL/entry.ts create mode 100644 app/constants/graphql.ts create mode 100644 app/utils/graphql.ts create mode 100644 types/api/graphql.d.ts diff --git a/app/actions/local/group.ts b/app/actions/local/group.ts index a14c38595e..fc17fadc8e 100644 --- a/app/actions/local/group.ts +++ b/app/actions/local/group.ts @@ -37,8 +37,7 @@ export const searchGroupsByNameInTeam = async (serverUrl: string, name: string, try { database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; } catch (e) { - // eslint-disable-next-line no-console - console.log('searchGroupsByNameInTeam - DB Error', e); + logError('searchGroupsByNameInTeam - DB Error', e); return []; } diff --git a/app/actions/remote/entry/app.ts b/app/actions/remote/entry/app.ts index fc9b27c6a2..bf88151eca 100644 --- a/app/actions/remote/entry/app.ts +++ b/app/actions/remote/entry/app.ts @@ -4,13 +4,14 @@ import {switchToChannelById} from '@actions/remote/channel'; import {fetchConfigAndLicense} from '@actions/remote/systems'; import DatabaseManager from '@database/manager'; -import {prepareCommonSystemValues, getCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId, getCurrentChannelId} from '@queries/servers/system'; +import {prepareCommonSystemValues, getCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId, getConfig, getCurrentChannelId} from '@queries/servers/system'; import {getCurrentUser} from '@queries/servers/user'; import {deleteV1Data} from '@utils/file'; import {isTablet} from '@utils/helpers'; -import {logInfo} from '@utils/log'; +import {logDebug, logInfo} from '@utils/log'; import {deferredAppEntryActions, entry, registerDeviceToken, syncOtherServers, verifyPushProxy} from './common'; +import {graphQLCommon} from './gql_common'; export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -30,6 +31,33 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) operator.batchRecords(removeLastUnreadChannelId); } + const config = await getConfig(database); + let result; + if (config?.FeatureFlagGraphQL === 'true') { + const {currentTeamId, currentChannelId} = await getCommonSystemValues(database); + result = await graphQLCommon(serverUrl, true, currentTeamId, currentChannelId, isUpgrade); + if (result.error) { + logDebug('Error using GraphQL, trying REST', result.error); + result = restAppEntry(serverUrl, since, isUpgrade); + } + } else { + result = restAppEntry(serverUrl, since, isUpgrade); + } + + if (!since) { + // Load data from other servers + syncOtherServers(serverUrl); + } + return result; +} + +async function restAppEntry(serverUrl: string, since = 0, isUpgrade = false) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + const {database} = operator; + const tabletDevice = await isTablet(); const currentTeamId = await getCurrentTeamId(database); const currentChannelId = await getCurrentChannelId(database); @@ -39,6 +67,7 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) if ('error' in entryData) { return {error: entryData.error}; } + const {models, initialTeamId, initialChannelId, prefData, teamData, chData, meData} = entryData; if (isUpgrade && meData?.user) { const me = await prepareCommonSystemValues(operator, {currentUserId: meData.user.id}); @@ -65,10 +94,6 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) const {id: currentUserId, locale: currentUserLocale} = meData?.user || (await getCurrentUser(database))!; const {config, license} = await getCommonSystemValues(database); await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchToChannel ? initialChannelId : undefined); - if (!since) { - // Load data from other servers - syncOtherServers(serverUrl); - } verifyPushProxy(serverUrl); diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index eef61b9c7f..ee07b6f1a7 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -11,6 +11,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems'; import {fetchAllTeams, fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from '@actions/remote/team'; import {fetchNewThreads} from '@actions/remote/thread'; import {fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user'; +import {gqlAllChannels} from '@client/graphQL/entry'; import {Preferences} 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'; @@ -21,12 +22,14 @@ import {DEFAULT_LOCALE} from '@i18n'; import NetworkManager from '@managers/network_manager'; import {getDeviceToken} from '@queries/app/global'; import {queryAllServers} from '@queries/app/servers'; -import {queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel'; +import {prepareMyChannelsForTeam, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel'; import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry'; import {getHasCRTChanged} from '@queries/servers/preference'; -import {getConfig, getPushVerificationStatus, getWebSocketLastDisconnected} from '@queries/servers/system'; +import {getConfig, getCurrentUserId, getPushVerificationStatus, getWebSocketLastDisconnected} from '@queries/servers/system'; import {deleteMyTeams, getAvailableTeamIds, getNthLastChannelFromTeam, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team'; import {isDMorGM} from '@utils/channel'; +import {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql'; +import {logDebug} from '@utils/log'; import {processIsCRTEnabled} from '@utils/thread'; import {fetchGroupsForMember} from '../groups'; @@ -61,12 +64,12 @@ export type EntryResponse = { } const FETCH_MISSING_DM_TIMEOUT = 2500; -const FETCH_UNREADS_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 undefined; + return []; } const {database} = operator; @@ -81,7 +84,7 @@ export const teamsToRemove = async (serverUrl: string, removeTeamIds?: string[]) } } - return undefined; + return []; }; export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0): Promise => { @@ -360,6 +363,51 @@ const syncAllChannelMembersAndThreads = async (serverUrl: string) => { 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); diff --git a/app/actions/remote/entry/gql_common.ts b/app/actions/remote/entry/gql_common.ts new file mode 100644 index 0000000000..5bab09cd5e --- /dev/null +++ b/app/actions/remote/entry/gql_common.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; + +import {markChannelAsRead, MyChannelsRequest} from '@actions/remote/channel'; +import {fetchGroupsForMember} from '@actions/remote/groups'; +import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote/post'; +import {MyTeamsRequest} from '@actions/remote/team'; +import {fetchNewThreads} from '@actions/remote/thread'; +import {MyUserRequest, updateAllUsersSince} from '@actions/remote/user'; +import {gqlEntry, gqlEntryChannels, gqlOtherChannels} from '@client/graphQL/entry'; +import {Preferences} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getPreferenceValue} from '@helpers/api/preference'; +import {selectDefaultTeam} from '@helpers/api/team'; +import {queryAllChannels, queryAllChannelsForTeam} from '@queries/servers/channel'; +import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry'; +import {getHasCRTChanged} from '@queries/servers/preference'; +import {prepareCommonSystemValues} from '@queries/servers/system'; +import {addChannelToTeamHistory, addTeamToTeamHistory, queryMyTeams} from '@queries/servers/team'; +import {selectDefaultChannelForTeam} from '@utils/channel'; +import {filterAndTransformRoles, getMemberChannelsFromGQLQuery, getMemberTeamsFromGQLQuery, gqlToClientChannelMembership, gqlToClientPreference, gqlToClientSidebarCategory, gqlToClientTeamMembership, gqlToClientUser} from '@utils/graphql'; +import {isTablet} from '@utils/helpers'; +import {processIsCRTEnabled} from '@utils/thread'; + +import {teamsToRemove, FETCH_UNREADS_TIMEOUT} from './common'; + +import type ClientError from '@client/rest/error'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type TeamModel from '@typings/database/models/servers/team'; + +export async function deferredAppEntryGraphQLActions( + serverUrl: string, + since: number, + meData: MyUserRequest, + teamData: MyTeamsRequest, + chData: MyChannelsRequest | undefined, + isTabletDevice: boolean, + initialTeamId?: string, + initialChannelId?: string, + isCRTEnabled = false, + syncDatabase?: boolean, +) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + const {database} = operator; + + // defer fetching posts for initial channel + if (initialChannelId && isTabletDevice) { + fetchPostsForChannel(serverUrl, initialChannelId); + markChannelAsRead(serverUrl, initialChannelId); + } + + setTimeout(() => { + if (chData?.channels?.length && chData.memberships?.length) { + // defer fetching posts for unread channels on initial team + fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId); + } + }, FETCH_UNREADS_TIMEOUT); + + if (isCRTEnabled) { + 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); + } + } + } + } + + if (initialTeamId) { + const result = await getChannelData(serverUrl, initialTeamId, meData.user!.id, true); + if ('error' in result) { + return result; + } + + const removeChannels = await getRemoveChannels(database, result.chData, initialTeamId, false, syncDatabase); + + const modelPromises = await prepareModels({operator, removeChannels, chData: result.chData}, true); + + modelPromises.push(operator.handleRole({roles: filterAndTransformRoles(result.roles), prepareRecordsOnly: true})); + const models = (await Promise.all(modelPromises)).flat(); + operator.batchRecords(models); + + setTimeout(() => { + if (result.chData?.channels?.length && result.chData.memberships?.length) { + // defer fetching posts for unread channels on other teams + fetchPostsForUnreadChannels(serverUrl, result.chData.channels, result.chData.memberships, initialChannelId); + } + }, FETCH_UNREADS_TIMEOUT); + } + + if (meData.user?.id) { + // Fetch groups for current user + fetchGroupsForMember(serverUrl, meData.user?.id); + } + + updateAllUsersSince(serverUrl, since); + + return {}; +} + +const getRemoveChannels = async (database: Database, chData: MyChannelsRequest | undefined, initialTeamId: string, singleTeam: boolean, syncDatabase?: boolean) => { + const removeChannels: ChannelModel[] = []; + if (syncDatabase) { + if (chData?.channels) { + const fetchedChannelIds = chData.channels?.map((channel) => channel.id); + + const query = singleTeam ? queryAllChannelsForTeam(database, initialTeamId) : queryAllChannels(database); + const channels = await query.fetch(); + + for (const channel of channels) { + const excludeCondition = singleTeam ? true : channel.teamId !== initialTeamId && channel.teamId !== ''; + if (excludeCondition && !fetchedChannelIds?.includes(channel.id)) { + removeChannels.push(channel); + } + } + } + } + + return removeChannels; +}; + +const getChannelData = async (serverUrl: string, initialTeamId: string, userId: string, exclude: boolean): Promise<{chData: MyChannelsRequest; roles: Array|undefined>} | {error: unknown}> => { + let response; + try { + const request = exclude ? gqlOtherChannels : gqlEntryChannels; + response = await request(serverUrl, initialTeamId); + } catch (error) { + return {error: (error as ClientError).message}; + } + + if ('error' in response) { + return {error: response.error}; + } + + if ('errors' in response && response.errors?.length) { + return {error: response.errors[0].message}; + } + + const channelsFetchedData = response.data; + + const chData = { + channels: getMemberChannelsFromGQLQuery(channelsFetchedData), + memberships: channelsFetchedData.channelMembers?.map((m) => gqlToClientChannelMembership(m, userId)), + categories: channelsFetchedData.sidebarCategories?.map((c) => gqlToClientSidebarCategory(c, '')), + }; + const roles = channelsFetchedData.channelMembers?.map((m) => m.roles).flat() || []; + + return {chData, roles}; +}; + +export const graphQLCommon = async (serverUrl: string, syncDatabase: boolean, currentTeamId: string, currentChannelId: string, isUpgrade = false) => { + const dt = Date.now(); + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + const {database} = operator; + + const isTabletDevice = await isTablet(); + + let response; + try { + response = await gqlEntry(serverUrl); + } catch (error) { + return {error: (error as ClientError).message}; + } + + if ('error' in response) { + return {error: response.error}; + } + + if ('errors' in response && response.errors?.length) { + return {error: response.errors[0].message}; + } + + const fetchedData = response.data; + + const config = fetchedData.config || {} as ClientConfig; + const license = fetchedData.license || {} as ClientLicense; + + const meData = { + user: gqlToClientUser(fetchedData.user!), + }; + + const teamData = { + teams: getMemberTeamsFromGQLQuery(fetchedData), + memberships: fetchedData.teamMembers.map((m) => gqlToClientTeamMembership(m, meData.user.id)), + }; + + const prefData = { + preferences: fetchedData.user?.preferences?.map(gqlToClientPreference), + }; + + if (prefData.preferences) { + const crtToggled = await getHasCRTChanged(database, prefData.preferences); + if (crtToggled) { + const {error} = await truncateCrtRelatedTables(serverUrl); + if (error) { + return {error: `Resetting CRT on ${serverUrl} failed`}; + } + } + } + + if (isUpgrade && meData?.user) { + const me = await prepareCommonSystemValues(operator, {currentUserId: meData.user.id}); + if (me?.length) { + await operator.batchRecords(me); + } + } + + let initialTeamId = currentTeamId; + if (!teamData.teams.length) { + initialTeamId = ''; + } else if (!initialTeamId || !teamData.teams.find((t) => t.id === currentTeamId)) { + const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string; + initialTeamId = selectDefaultTeam(teamData.teams, meData.user.locale, teamOrderPreference, config.ExperimentalPrimaryTeam)?.id || ''; + } + const gqlRoles = [ + ...fetchedData.user?.roles || [], + ...fetchedData.teamMembers?.map((m) => m.roles).flat() || [], + ]; + + let chData; + if (initialTeamId) { + const result = await getChannelData(serverUrl, initialTeamId, meData.user.id, false); + if ('error' in result) { + return result; + } + + chData = result.chData; + gqlRoles.push(...result.roles); + } + + const roles = filterAndTransformRoles(gqlRoles); + + let initialChannelId = currentChannelId; + if (initialTeamId !== currentTeamId || !chData?.channels?.find((c) => c.id === currentChannelId)) { + initialChannelId = ''; + if (isTabletDevice && chData?.channels && chData.memberships) { + initialChannelId = selectDefaultChannelForTeam(chData.channels, chData.memberships, initialTeamId, roles, meData.user.locale)?.id || ''; + } + } + + let removeTeams: TeamModel[] = []; + const removeChannels = await getRemoveChannels(database, chData, initialTeamId, true, syncDatabase); + + if (syncDatabase) { + const removeTeamIds = []; + + const removedFromTeam = teamData.memberships?.filter((m) => m.delete_at > 0); + if (removedFromTeam?.length) { + removeTeamIds.push(...removedFromTeam.map((m) => m.team_id)); + } + + if (teamData.teams?.length === 0) { + // User is no longer a member of any team + const myTeams = await queryMyTeams(database).fetch(); + removeTeamIds.push(...(myTeams?.map((myTeam) => myTeam.id) || [])); + } + + removeTeams = await teamsToRemove(serverUrl, removeTeamIds); + } + + const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData}, true); + modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true})); + modelPromises.push(prepareCommonSystemValues( + operator, + { + config, + license, + currentTeamId: initialTeamId, + currentChannelId: initialChannelId, + }, + )); + + if (initialTeamId && initialTeamId !== currentTeamId) { + const th = addTeamToTeamHistory(operator, initialTeamId, true); + modelPromises.push(th); + } + + if (initialTeamId !== currentTeamId && initialChannelId) { + try { + const tch = addChannelToTeamHistory(operator, initialTeamId, initialChannelId, true); + modelPromises.push(tch); + } catch { + // do nothing + } + } + + const models = await Promise.all(modelPromises); + if (models.length) { + await operator.batchRecords(models.flat()); + } + + const isCRTEnabled = Boolean(prefData.preferences && processIsCRTEnabled(prefData.preferences, config)); + deferredAppEntryGraphQLActions(serverUrl, 0, meData, teamData, chData, isTabletDevice, initialTeamId, initialChannelId, isCRTEnabled, syncDatabase); + + const timeElapsed = Date.now() - dt; + return {time: timeElapsed, hasTeams: Boolean(teamData.teams.length), userId: meData.user.id, error: undefined}; +}; diff --git a/app/actions/remote/entry/login.ts b/app/actions/remote/entry/login.ts index 267232f74f..05945e93c1 100644 --- a/app/actions/remote/entry/login.ts +++ b/app/actions/remote/entry/login.ts @@ -6,12 +6,13 @@ import {getSessions} from '@actions/remote/session'; import {ConfigAndLicenseRequest, fetchConfigAndLicense} from '@actions/remote/systems'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; -import {prepareCommonSystemValues, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {setCurrentTeamAndChannelId} from '@queries/servers/system'; import {isTablet} from '@utils/helpers'; -import {logWarning} from '@utils/log'; +import {logDebug, logWarning} from '@utils/log'; import {scheduleExpiredNotification} from '@utils/notification'; -import {deferredAppEntryActions, entry, EntryResponse} from './common'; +import {deferredAppEntryActions, entry} from './common'; +import {graphQLCommon} from './gql_common'; import type {Client} from '@client/rest'; @@ -21,8 +22,13 @@ type AfterLoginArgs = { deviceToken?: string; } -export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs) { - const dt = Date.now(); +type SpecificAfterLoginArgs = { + serverUrl: string; + user: UserProfile; + clData: ConfigAndLicenseRequest; +} + +export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs): Promise<{error?: any; hasTeams?: boolean; time?: number}> { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -44,18 +50,9 @@ export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs) } try { - const isTabletDevice = await isTablet(); - - // Fetch in parallel server config & license / user preferences / teams / team membership - const promises: [Promise, Promise] = [ - fetchConfigAndLicense(serverUrl, true), - entry(serverUrl, '', ''), - ]; - - const [clData, entryData] = await Promise.all(promises); - - if ('error' in entryData) { - return {error: entryData.error}; + const clData = await fetchConfigAndLicense(serverUrl, true); + if (clData.error) { + return {error: clData.error}; } // schedule local push notification if needed @@ -79,33 +76,51 @@ export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs) } } - let switchToChannel = false; - const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData; - if (initialChannelId && isTabletDevice) { - switchToChannel = true; - switchToChannelById(serverUrl, initialChannelId, initialTeamId); - } else { - setCurrentTeamAndChannelId(operator, initialTeamId, ''); + if (clData.config?.FeatureFlagGraphQL === 'true') { + const result = await graphQLCommon(serverUrl, false, '', ''); + if (!result.error) { + return result; + } + logDebug('Error using GraphQL, trying REST', result.error); } - await operator.batchRecords(models); - - const config = clData.config || {} as ClientConfig; - const license = clData.license || {} as ClientLicense; - deferredAppEntryActions(serverUrl, 0, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchToChannel ? initialChannelId : undefined); - - return {time: Date.now() - dt, hasTeams: Boolean(teamData.teams?.length)}; + return restLoginEntry({serverUrl, user, clData}); } catch (error) { - const systemModels = await prepareCommonSystemValues(operator, { - config: ({} as ClientConfig), - license: ({} as ClientLicense), - currentTeamId: '', - currentChannelId: '', - }); - if (systemModels) { - await operator.batchRecords(systemModels); - } - return {error}; } } + +const restLoginEntry = async ({serverUrl, user, clData}: SpecificAfterLoginArgs) => { + const dt = Date.now(); + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const entryData = await entry(serverUrl, '', ''); + + if ('error' in entryData) { + return {error: entryData.error}; + } + + const isTabletDevice = await isTablet(); + + const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData; + + let switchToChannel = false; + if (initialChannelId && isTabletDevice) { + switchToChannel = true; + switchToChannelById(serverUrl, initialChannelId, initialTeamId); + } else { + setCurrentTeamAndChannelId(operator, initialTeamId, ''); + } + + await operator.batchRecords(models); + + const config = clData.config || {} as ClientConfig; + const license = clData.license || {} as ClientLicense; + deferredAppEntryActions(serverUrl, 0, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchToChannel ? initialChannelId : undefined); + + return {time: Date.now() - dt, hasTeams: Boolean(teamData.teams?.length)}; +}; diff --git a/app/actions/remote/entry/notification.ts b/app/actions/remote/entry/notification.ts index f7486de54b..c02cc4d9f5 100644 --- a/app/actions/remote/entry/notification.ts +++ b/app/actions/remote/entry/notification.ts @@ -8,17 +8,19 @@ import {getDefaultThemeByAppearance} from '@context/theme'; import DatabaseManager from '@database/manager'; import {getMyChannel} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; -import {getCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {getCommonSystemValues, getConfig, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system'; import {getMyTeamById} from '@queries/servers/team'; import {getIsCRTEnabled} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; import {isTablet} from '@utils/helpers'; +import {logDebug} from '@utils/log'; import {emitNotificationError} from '@utils/notification'; import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme'; import {deferredAppEntryActions, entry, syncOtherServers} from './common'; +import {graphQLCommon} from './gql_common'; export async function pushNotificationEntry(serverUrl: string, notification: NotificationWithData) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -26,14 +28,11 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not return {error: `${serverUrl} database not found`}; } - const isTabletDevice = await isTablet(); - // We only reach this point if we have a channel Id in the notification payload const channelId = notification.payload!.channel_id!; const rootId = notification.payload!.root_id!; const {database} = operator; const currentTeamId = await getCurrentTeamId(database); - const lastDisconnectedAt = await getWebSocketLastDisconnected(database); const currentServerUrl = await DatabaseManager.getActiveServerUrl(); let isDirectChannel = false; @@ -48,13 +47,6 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not await DatabaseManager.setActiveServerDatabase(serverUrl); } - // To make the switch faster we determine if we already have the team & channel - const myChannel = await getMyChannel(database, channelId); - const myTeam = await getMyTeamById(database, teamId); - - const isCRTEnabled = await getIsCRTEnabled(database); - const isThreadNotification = isCRTEnabled && Boolean(rootId); - if (!EphemeralStore.theme) { // When opening the app from a push notification the theme may not be set in the EphemeralStore // causing the goToScreen to use the Appearance theme instead and that causes the screen background color to potentially @@ -69,18 +61,30 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not await NavigationStore.waitUntilScreenHasLoaded(Screens.HOME); - let switchedToScreen = false; - let switchedToChannel = false; - if (myChannel && myTeam) { - if (isThreadNotification) { - await fetchAndSwitchToThread(serverUrl, rootId, true); - } else { - switchedToChannel = true; - await switchToChannelById(serverUrl, channelId, teamId); + const config = await getConfig(database); + let result; + if (config?.FeatureFlagGraphQL === 'true') { + result = await graphQLCommon(serverUrl, true, teamId, channelId); + if (result.error) { + logDebug('Error using GraphQL, trying REST', result.error); + result = restNotificationEntry(serverUrl, teamId, channelId, rootId, isDirectChannel); } - switchedToScreen = true; + } else { + result = restNotificationEntry(serverUrl, teamId, channelId, rootId, isDirectChannel); } + syncOtherServers(serverUrl); + + return result; +} + +const restNotificationEntry = async (serverUrl: string, teamId: string, channelId: string, rootId: string, isDirectChannel: boolean) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + const {database} = operator; + const entryData = await entry(serverUrl, teamId, channelId); if ('error' in entryData) { return {error: entryData.error}; @@ -102,7 +106,25 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not } } + const myChannel = await getMyChannel(database, channelId); + const myTeam = await getMyTeamById(database, teamId); + 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, channelId, teamId); + } + switchedToScreen = true; + } + if (!switchedToScreen) { + const isTabletDevice = await isTablet(); if (isTabletDevice || (selectedChannelId === channelId)) { // Make switch again to get the missing data and make sure the team is the correct one switchedToScreen = true; @@ -130,8 +152,8 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(operator.database))!; const {config, license} = await getCommonSystemValues(operator.database); + const lastDisconnectedAt = await getWebSocketLastDisconnected(database); await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, selectedTeamId, switchedToChannel ? selectedChannelId : undefined); - syncOtherServers(serverUrl); return {userId: currentUserId}; -} +}; diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 2eeb072c68..19f1f5e86d 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -5,6 +5,8 @@ import {DeviceEventEmitter} from 'react-native'; import {switchToChannelById} from '@actions/remote/channel'; import {deferredAppEntryActions, entry} from '@actions/remote/entry/common'; +import {graphQLCommon} from '@actions/remote/entry/gql_common'; +import {fetchConfigAndLicense} from '@actions/remote/systems'; import {fetchStatusByIds} from '@actions/remote/user'; import {loadConfigAndCalls} from '@calls/actions/calls'; import { @@ -19,6 +21,7 @@ import {isSupportedServerCalls} from '@calls/utils'; import {Events, Screens, WebsocketEvents} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import ServerDataOperator from '@database/operator/server_data_operator'; import {getActiveServerUrl, queryActiveServer} from '@queries/app/servers'; import {getCurrentChannel} from '@queries/servers/channel'; import { @@ -104,21 +107,13 @@ export async function handleClose(serverUrl: string, lastDisconnect: number) { }); } -async function doReconnect(serverUrl: string) { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return; - } - +async function doReconnectRest(serverUrl: string, operator: ServerDataOperator, currentTeamId: string, currentUserId: string, config: ClientConfig, license: ClientLicense, lastDisconnectedAt: number) { const appDatabase = DatabaseManager.appDatabase?.database; if (!appDatabase) { return; } const {database} = operator; - const tabletDevice = await isTablet(); - const lastDisconnectedAt = await getWebSocketLastDisconnected(database); - resetWebSocketLastDisconnected(operator); const currentTeam = await getCurrentTeam(database); const currentChannel = await getCurrentChannel(database); const currentActiveServerUrl = await getActiveServerUrl(DatabaseManager.appDatabase!.database); @@ -152,6 +147,8 @@ async function doReconnect(serverUrl: string) { await popToRoot(); } + const tabletDevice = await isTablet(); + if (tabletDevice && initialChannelId) { switchedToChannel = true; switchToChannelById(serverUrl, initialChannelId, initialTeamId); @@ -167,8 +164,7 @@ async function doReconnect(serverUrl: string) { await operator.batchRecords(models); logInfo('WEBSOCKET RECONNECT MODELS BATCHING TOOK', `${Date.now() - dt}ms`); - const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(database))!; - const {config, license} = await getCommonSystemValues(database); + const {locale: currentUserLocale} = (await getCurrentUser(database))!; await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchedToChannel ? initialChannelId : undefined); if (isSupportedServerCalls(config?.Version)) { @@ -178,6 +174,33 @@ async function doReconnect(serverUrl: string) { // https://mattermost.atlassian.net/browse/MM-41520 } +async function doReconnect(serverUrl: string) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return; + } + + const {database} = operator; + const system = await getCommonSystemValues(database); + const lastDisconnectedAt = await getWebSocketLastDisconnected(database); + + resetWebSocketLastDisconnected(operator); + let {config, license} = await fetchConfigAndLicense(serverUrl); + if (!config) { + config = system.config; + } + + if (!license) { + license = system.license; + } + + if (config.FeatureFlagGraphQL === 'true') { + await graphQLCommon(serverUrl, true, system.currentTeamId, system.currentChannelId); + } else { + await doReconnectRest(serverUrl, operator, system.currentTeamId, system.currentUserId, config, license, lastDisconnectedAt); + } +} + export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { switch (msg.event) { case WebsocketEvents.POSTED: diff --git a/app/client/graphQL/constants.ts b/app/client/graphQL/constants.ts new file mode 100644 index 0000000000..21328232b7 --- /dev/null +++ b/app/client/graphQL/constants.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const QUERY_ENTRY = 'gql_m_entry'; +export const QUERY_CHANNELS = 'gql_m_channels'; +export const QUERY_CHANNELS_NEXT = 'gql_m_channels_next'; +export const QUERY_ALL_CHANNELS = 'gql_m_all_channels'; +export const QUERY_ALL_CHANNELS_NEXT = 'gql_m_all_channels_next'; + +export default { + QUERY_ENTRY, + QUERY_CHANNELS, + QUERY_CHANNELS_NEXT, + QUERY_ALL_CHANNELS, + QUERY_ALL_CHANNELS_NEXT, +}; diff --git a/app/client/graphQL/entry.ts b/app/client/graphQL/entry.ts new file mode 100644 index 0000000000..6a6132662a --- /dev/null +++ b/app/client/graphQL/entry.ts @@ -0,0 +1,371 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {MEMBERS_PER_PAGE} from '@constants/graphql'; +import NetworkManager from '@managers/network_manager'; + +import {Client} from '../rest'; + +import QueryNames from './constants'; + +const doGQLQuery = async (serverUrl: string, query: string, variables: {[name: string]: any}, operationName: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const response = await client.doFetch('/api/v5/graphql', {method: 'post', body: JSON.stringify({query, variables, operationName})}) as GQLResponse; + return response; + } catch (error) { + return {error}; + } +}; + +export const gqlEntry = async (serverUrl: string) => { + return doGQLQuery(serverUrl, entryQuery, {}, QueryNames.QUERY_ENTRY); +}; + +export const gqlEntryChannels = async (serverUrl: string, teamId: string) => { + const variables = { + teamId, + exclude: false, + perPage: MEMBERS_PER_PAGE, + }; + const response = await doGQLQuery(serverUrl, channelsQuery, variables, QueryNames.QUERY_CHANNELS); + if ('error' in response || response.errors) { + return response; + } + + let members = response.data.channelMembers; + + while (members?.length === MEMBERS_PER_PAGE) { + let pageResponse; + try { + // eslint-disable-next-line no-await-in-loop + pageResponse = await gqlEntryChannelsNextPage(serverUrl, teamId, members[members.length - 1].cursor!, false); + } catch { + break; + } + + if ('error' in pageResponse) { + break; + } + + members = pageResponse.data.channelMembers!; + response.data.channelMembers?.push(...members); + } + + return response; +}; +const gqlEntryChannelsNextPage = async (serverUrl: string, teamId: string, cursor: string, exclude: boolean) => { + const variables = { + teamId, + exclude, + perPage: MEMBERS_PER_PAGE, + cursor, + }; + return doGQLQuery(serverUrl, nextPageChannelsQuery, variables, QueryNames.QUERY_CHANNELS_NEXT); +}; + +export const gqlOtherChannels = async (serverUrl: string, teamId: string) => { + const variables = { + teamId, + exclude: true, + perPage: MEMBERS_PER_PAGE, + }; + const response = await doGQLQuery(serverUrl, channelsQuery, variables, QueryNames.QUERY_CHANNELS); + if ('error' in response || response.errors) { + return response; + } + + let members = response.data.channelMembers; + + while (members?.length === MEMBERS_PER_PAGE) { + let pageResponse; + try { + // eslint-disable-next-line no-await-in-loop + pageResponse = await gqlEntryChannelsNextPage(serverUrl, teamId, members[members.length - 1].cursor!, true); + } catch { + break; + } + + if ('error' in pageResponse || 'errors' in pageResponse) { + break; + } + + members = pageResponse.data.channelMembers!; + response.data.channelMembers?.push(...members); + } + + return response; +}; + +export const gqlAllChannels = async (serverUrl: string) => { + const variables = { + perPage: MEMBERS_PER_PAGE, + }; + const response = await doGQLQuery(serverUrl, allChannelsQuery, variables, QueryNames.QUERY_ALL_CHANNELS); + if ('error' in response || response.errors) { + return response; + } + + let members = response.data.channelMembers; + + while (members?.length === MEMBERS_PER_PAGE) { + let pageResponse; + try { + // eslint-disable-next-line no-await-in-loop + pageResponse = await gqlAllChannelsNextPage(serverUrl, members[members.length - 1].cursor!); + } catch { + break; + } + + if ('error' in pageResponse || 'errors' in pageResponse) { + break; + } + + members = pageResponse.data.channelMembers!; + response.data.channelMembers?.push(...members); + } + + return response; +}; + +const gqlAllChannelsNextPage = async (serverUrl: string, cursor: string) => { + const variables = { + perPage: MEMBERS_PER_PAGE, + cursor, + }; + return doGQLQuery(serverUrl, nextPageAllChannelsQuery, variables, QueryNames.QUERY_ALL_CHANNELS_NEXT); +}; + +const entryQuery = ` +query ${QueryNames.QUERY_ENTRY} { + config + license + user(id:"me") { + id + createAt + updateAt + deleteAt + username + authService + email + emailVerified + nickname + firstName + lastName + position + roles { + id + name + permissions + } + locale + notifyProps + props + timezone + isBot + lastPictureUpdate + remoteId + status { + status + } + botDescription + botLastIconUpdate + preferences{ + category + name + value + userId + } + sessions { + createAt + expiresAt + } + } + teamMembers(userId:"me") { + deleteAt + roles { + id + name + permissions + } + team { + id + description + displayName + name + type + allowedDomains + lastTeamIconUpdate + groupConstrained + allowOpenInvite + createAt + updateAt + deleteAt + schemeId + policyId + cloudLimitsArchived + } + } +} +`; + +const channelsQuery = ` +query ${QueryNames.QUERY_CHANNELS}($teamId: String!, $perPage: Int!, $exclude: Boolean!) { + channelMembers(userId:"me", first:$perPage, teamId:$teamId, excludeTeam:$exclude) { + cursor + msgCount + msgCountRoot + mentionCount + lastViewedAt + notifyProps + roles { + id + name + permissions + } + channel { + id + header + purpose + type + createAt + creatorId + deleteAt + displayName + prettyDisplayName + groupConstrained + name + shared + lastPostAt + totalMsgCount + totalMsgCountRoot + lastRootPostAt + team { + id + } + } + } + sidebarCategories(userId:"me", teamId:$teamId, excludeTeam:$exclude) { + displayName + id + sorting + type + muted + collapsed + channelIds + teamId + } +} +`; + +const nextPageChannelsQuery = ` +query ${QueryNames.QUERY_CHANNELS_NEXT}($teamId: String!, $perPage: Int!, $exclude: Boolean!, $cursor: String!) { + channelMembers(userId:"me", first:$perPage, after:$cursor, teamId:$teamId, excludeTeam:$exclude) { + cursor + msgCount + msgCountRoot + mentionCount + lastViewedAt + notifyProps + roles { + id + name + permissions + } + channel { + id + header + purpose + type + createAt + creatorId + deleteAt + displayName + prettyDisplayName + groupConstrained + name + shared + lastPostAt + totalMsgCount + totalMsgCountRoot + lastRootPostAt + team { + id + } + } + } +} +`; + +const allChannelsQuery = ` +query ${QueryNames.QUERY_ALL_CHANNELS}($perPage: Int!){ + channelMembers(userId:"me", first:$perPage) { + cursor + msgCount + msgCountRoot + mentionCount + lastViewedAt + notifyProps + channel { + id + header + purpose + type + createAt + creatorId + deleteAt + displayName + prettyDisplayName + groupConstrained + name + shared + lastPostAt + totalMsgCount + totalMsgCountRoot + lastRootPostAt + team { + id + } + } + } +} +`; + +const nextPageAllChannelsQuery = ` +query ${QueryNames.QUERY_ALL_CHANNELS_NEXT}($perPage: Int!, $cursor: String!) { + channelMembers(userId:"me", first:$perPage, after:$cursor) { + cursor + msgCount + msgCountRoot + mentionCount + lastViewedAt + notifyProps + channel { + id + header + purpose + type + createAt + creatorId + deleteAt + displayName + prettyDisplayName + groupConstrained + name + shared + lastPostAt + totalMsgCount + totalMsgCountRoot + lastRootPostAt + team { + id + } + } + } +} +`; diff --git a/app/components/custom_status/custom_status_emoji.test.tsx b/app/components/custom_status/custom_status_emoji.test.tsx index f6d641dc7a..6dc4b6fe65 100644 --- a/app/components/custom_status/custom_status_emoji.test.tsx +++ b/app/components/custom_status/custom_status_emoji.test.tsx @@ -5,7 +5,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; -import {CustomStatusDuration} from '@constants'; +import {CustomStatusDurationEnum} from '@constants/custom_status'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; @@ -19,7 +19,7 @@ describe('components/custom_status/custom_status_emoji', () => { const customStatus: UserCustomStatus = { emoji: 'calendar', text: 'In a meeting', - duration: CustomStatusDuration.DONT_CLEAR, + duration: CustomStatusDurationEnum.DONT_CLEAR, }; it('should match snapshot', () => { const wrapper = renderWithEverything( diff --git a/app/components/profile_picture/image.tsx b/app/components/profile_picture/image.tsx index 27c8a448b2..229012db89 100644 --- a/app/components/profile_picture/image.tsx +++ b/app/components/profile_picture/image.tsx @@ -71,7 +71,7 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => { if (isBot) { lastPictureUpdate = ('isBot' in author) ? author.props?.bot_last_icon_update : author.bot_last_icon_update || 0; } else { - lastPictureUpdate = ('lastPictureUpdate' in author) ? author.lastPictureUpdate : author.last_picture_update; + lastPictureUpdate = ('lastPictureUpdate' in author) ? author.lastPictureUpdate : author.last_picture_update || 0; } const pictureUrl = client.getProfilePictureUrl(author.id, lastPictureUpdate); diff --git a/app/constants/custom_status.ts b/app/constants/custom_status.ts index ba9a5bf50c..b949937c70 100644 --- a/app/constants/custom_status.ts +++ b/app/constants/custom_status.ts @@ -3,7 +3,7 @@ import {t} from '@i18n'; -export enum CustomStatusDuration { +export enum CustomStatusDurationEnum { DONT_CLEAR = '', THIRTY_MINUTES = 'thirty_minutes', ONE_HOUR = 'one_hour', @@ -21,9 +21,9 @@ const { TODAY, THIS_WEEK, DATE_AND_TIME, -} = CustomStatusDuration; +} = CustomStatusDurationEnum; -export const CST = { +export const CST: {[key in CustomStatusDuration]: {id: string; defaultMessage: string}} = { [DONT_CLEAR]: { id: t('custom_status.expiry_dropdown.dont_clear'), defaultMessage: "Don't clear", diff --git a/app/constants/graphql.ts b/app/constants/graphql.ts new file mode 100644 index 0000000000..99e36be965 --- /dev/null +++ b/app/constants/graphql.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const MEMBERS_PER_PAGE = 200; diff --git a/app/constants/index.ts b/app/constants/index.ts index 88c04fb2fe..8770e7ffe2 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -6,7 +6,6 @@ import Apps from './apps'; import Categories from './categories'; import Channel from './channel'; import Config from './config'; -import {CustomStatusDuration} from './custom_status'; import Database from './database'; import DateTime from './datetime'; import DeepLink from './deep_linking'; @@ -40,7 +39,6 @@ export { Categories, Channel, Config, - CustomStatusDuration, Database, DateTime, DeepLink, diff --git a/app/database/operator/server_data_operator/transformers/user.ts b/app/database/operator/server_data_operator/transformers/user.ts index 6c10cd93ce..4f3df663aa 100644 --- a/app/database/operator/server_data_operator/transformers/user.ts +++ b/app/database/operator/server_data_operator/transformers/user.ts @@ -33,7 +33,7 @@ export const transformUserRecord = ({action, database, value}: TransformerArgs): user.firstName = raw.first_name; user.isGuest = raw.roles.includes('system_guest'); user.lastName = raw.last_name; - user.lastPictureUpdate = raw.last_picture_update; + user.lastPictureUpdate = raw.last_picture_update || 0; user.locale = raw.locale; user.nickname = raw.nickname; user.position = raw?.position ?? ''; @@ -41,7 +41,7 @@ export const transformUserRecord = ({action, database, value}: TransformerArgs): user.username = raw.username; user.notifyProps = raw.notify_props; user.timezone = raw.timezone || null; - user.isBot = raw.is_bot; + user.isBot = raw.is_bot ?? false; user.remoteId = raw?.remote_id ?? null; if (raw.status) { user.status = raw.status; diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 394360a05a..91dd06d223 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -62,15 +62,18 @@ export function prepareMissingChannelsForAllTeams(operator: ServerDataOperator, } } -export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[], isCRTEnabled?: boolean) => { +export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[], isCRTEnabled?: boolean, isGraphQL = false) => { const {database} = operator; - const allChannelsForTeam = (await queryAllChannelsForTeam(database, teamId).fetch()). + + const channelsQuery = isGraphQL ? queryAllChannels(database) : queryAllChannelsForTeam(database, teamId); + const allChannelsForTeam = (await channelsQuery.fetch()). reduce((map: Record, channel) => { map[channel.id] = channel; return map; }, {}); - const allChannelsInfoForTeam = (await queryAllChannelsInfoForTeam(database, teamId).fetch()). + const channelInfosQuery = isGraphQL ? queryAllChannelsInfo(database) : queryAllChannelsInfoForTeam(database, teamId); + const allChannelsInfoForTeam = (await channelInfosQuery.fetch()). reduce((map: Record, info) => { map[info.id] = info; return map; @@ -161,10 +164,18 @@ export const prepareDeleteChannel = async (channel: ChannelModel): Promise { + return database.get(CHANNEL).query(); +}; + export const queryAllChannelsForTeam = (database: Database, teamId: string) => { return database.get(CHANNEL).query(Q.where('team_id', teamId)); }; +export const queryAllChannelsInfo = (database: Database) => { + return database.get(CHANNEL_INFO).query(); +}; + export const queryAllChannelsInfoForTeam = (database: Database, teamId: string) => { return database.get(CHANNEL_INFO).query( Q.on(CHANNEL, Q.where('team_id', teamId)), diff --git a/app/queries/servers/entry.ts b/app/queries/servers/entry.ts index f482cce8ff..3f9ed8b81a 100644 --- a/app/queries/servers/entry.ts +++ b/app/queries/servers/entry.ts @@ -42,7 +42,7 @@ const { MY_CHANNEL, } = MM_TABLES.SERVER; -export async function prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData, isCRTEnabled}: PrepareModelsArgs): Promise>> { +export async function prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData, isCRTEnabled}: PrepareModelsArgs, isGraphQL = false): Promise>> { const modelPromises: Array> = []; if (removeTeams?.length) { @@ -66,8 +66,12 @@ export async function prepareModels({operator, initialTeamId, removeTeams, remov modelPromises.push(prepareCategoryChannels(operator, chData.categories)); } - if (initialTeamId && chData?.channels?.length && chData.memberships?.length) { - modelPromises.push(...await prepareMyChannelsForTeam(operator, initialTeamId, chData.channels, chData.memberships, isCRTEnabled)); + if (chData?.channels?.length && chData.memberships?.length) { + if (isGraphQL) { + modelPromises.push(...await prepareMyChannelsForTeam(operator, '', chData.channels, chData.memberships, isCRTEnabled, true)); + } else if (initialTeamId) { + modelPromises.push(...await prepareMyChannelsForTeam(operator, initialTeamId, chData.channels, chData.memberships, isCRTEnabled, false)); + } } if (prefData?.preferences?.length) { diff --git a/app/screens/custom_status/components/clear_after.tsx b/app/screens/custom_status/components/clear_after.tsx index 9b637266c3..ce1a6f0fa1 100644 --- a/app/screens/custom_status/components/clear_after.tsx +++ b/app/screens/custom_status/components/clear_after.tsx @@ -8,7 +8,7 @@ import {Text, TouchableOpacity, View} from 'react-native'; import CompassIcon from '@components/compass_icon'; import CustomStatusExpiry from '@components/custom_status/custom_status_expiry'; import FormattedText from '@components/formatted_text'; -import {CustomStatusDuration, CST} from '@constants/custom_status'; +import {CST} from '@constants/custom_status'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import type {Moment} from 'moment-timezone'; @@ -54,7 +54,7 @@ const ClearAfter = ({duration, expiresAt, onOpenClearAfterModal, theme}: Props) const style = getStyleSheet(theme); const renderClearAfterTime = () => { - if (duration && duration === CustomStatusDuration.DATE_AND_TIME) { + if (duration && duration === 'date_and_time') { return ( void; @@ -75,7 +75,7 @@ const CustomStatusSuggestion = ({duration, emoji, expires_at, handleClear, handl } }, []); - const showCustomStatus = Boolean(duration && duration !== CustomStatusDuration.DATE_AND_TIME && isExpirySupported); + const showCustomStatus = Boolean(duration && duration !== 'date_and_time' && isExpirySupported); const clearButton = handleClear && expires_at ? ( @@ -118,7 +118,7 @@ const CustomStatusSuggestion = ({duration, emoji, expires_at, handleClear, handl {showCustomStatus && ( diff --git a/app/screens/custom_status/components/custom_status_suggestions.tsx b/app/screens/custom_status/components/custom_status_suggestions.tsx index beabe442f6..58513b4b3c 100644 --- a/app/screens/custom_status/components/custom_status_suggestions.tsx +++ b/app/screens/custom_status/components/custom_status_suggestions.tsx @@ -6,7 +6,6 @@ import {IntlShape} from 'react-intl'; import {View} from 'react-native'; import FormattedText from '@components/formatted_text'; -import {CustomStatusDuration} from '@constants/custom_status'; import {t} from '@i18n'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -24,7 +23,7 @@ type DefaultUserCustomStatus = { emoji: string; message: string; messageDefault: string; - durationDefault: string; + durationDefault: CustomStatusDuration; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -49,11 +48,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const defaultCustomStatusSuggestions: DefaultUserCustomStatus[] = [ - {emoji: 'calendar', message: t('custom_status.suggestions.in_a_meeting'), messageDefault: 'In a meeting', durationDefault: CustomStatusDuration.ONE_HOUR}, - {emoji: 'hamburger', message: t('custom_status.suggestions.out_for_lunch'), messageDefault: 'Out for lunch', durationDefault: CustomStatusDuration.THIRTY_MINUTES}, - {emoji: 'sneezing_face', message: t('custom_status.suggestions.out_sick'), messageDefault: 'Out sick', durationDefault: CustomStatusDuration.TODAY}, - {emoji: 'house', message: t('custom_status.suggestions.working_from_home'), messageDefault: 'Working from home', durationDefault: CustomStatusDuration.TODAY}, - {emoji: 'palm_tree', message: t('custom_status.suggestions.on_a_vacation'), messageDefault: 'On a vacation', durationDefault: CustomStatusDuration.THIS_WEEK}, + {emoji: 'calendar', message: t('custom_status.suggestions.in_a_meeting'), messageDefault: 'In a meeting', durationDefault: 'one_hour'}, + {emoji: 'hamburger', message: t('custom_status.suggestions.out_for_lunch'), messageDefault: 'Out for lunch', durationDefault: 'thirty_minutes'}, + {emoji: 'sneezing_face', message: t('custom_status.suggestions.out_sick'), messageDefault: 'Out sick', durationDefault: 'today'}, + {emoji: 'house', message: t('custom_status.suggestions.working_from_home'), messageDefault: 'Working from home', durationDefault: 'today'}, + {emoji: 'palm_tree', message: t('custom_status.suggestions.on_a_vacation'), messageDefault: 'On a vacation', durationDefault: 'this_week'}, ]; const CustomStatusSuggestions = ({ @@ -72,11 +71,11 @@ const CustomStatusSuggestions = ({ text: intl.formatMessage({id: status.message, defaultMessage: status.messageDefault}), duration: status.durationDefault, })). - filter((status: UserCustomStatus) => !recentCustomStatusTexts.has(status.text)). - map((status: UserCustomStatus, index: number, arr: UserCustomStatus[]) => ( + filter((status) => !recentCustomStatusTexts.has(status.text)). + map((status, index, arr) => ( { let initialCustomExpiryTime: Moment = getRoundedTime(currentTime); const isCurrentCustomStatusSet = !this.isCustomStatusExpired && (customStatus?.text || customStatus?.emoji); - if (isCurrentCustomStatusSet && customStatus?.duration === DATE_AND_TIME && customStatus?.expires_at) { + if (isCurrentCustomStatusSet && customStatus?.duration === 'date_and_time' && customStatus?.expires_at) { initialCustomExpiryTime = moment(customStatus?.expires_at); } this.state = { - duration: isCurrentCustomStatusSet ? customStatus?.duration ?? DONT_CLEAR : DEFAULT_DURATION, + duration: isCurrentCustomStatusSet ? customStatus?.duration ?? CustomStatusDurationEnum.DONT_CLEAR : DEFAULT_DURATION, emoji: isCurrentCustomStatusSet ? customStatus?.emoji : '', expires_at: initialCustomExpiryTime, text: isCurrentCustomStatusSet ? customStatus?.text : '', @@ -186,7 +186,7 @@ class CustomStatusModal extends NavigationComponent { if (isStatusSet) { let isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text && customStatus?.duration === duration; const expiresAt = this.calculateExpiryTime(duration); - if (isStatusSame && duration === DATE_AND_TIME) { + if (isStatusSame && duration === 'date_and_time') { isStatusSame = customStatus?.expires_at === expiresAt; } @@ -194,7 +194,7 @@ class CustomStatusModal extends NavigationComponent { const status: UserCustomStatus = { emoji: emoji || 'speech_balloon', text: text?.trim(), - duration: DONT_CLEAR, + duration: CustomStatusDurationEnum.DONT_CLEAR, }; if (customStatusExpirySupported) { @@ -210,7 +210,7 @@ class CustomStatusModal extends NavigationComponent { updateLocalCustomStatus(serverUrl, currentUser, status); this.setState({ - duration: status.duration, + duration: status.duration!, emoji: status.emoji, expires_at: moment(status.expires_at), text: status.text, @@ -238,19 +238,19 @@ class CustomStatusModal extends NavigationComponent { const {expires_at} = this.state; switch (duration) { - case THIRTY_MINUTES: + case 'thirty_minutes': return currentTime.add(30, 'minutes').seconds(0).milliseconds(0).toISOString(); - case ONE_HOUR: + case 'one_hour': return currentTime.add(1, 'hour').seconds(0).milliseconds(0).toISOString(); - case FOUR_HOURS: + case 'four_hours': return currentTime.add(4, 'hours').seconds(0).milliseconds(0).toISOString(); - case TODAY: + case 'today': return currentTime.endOf('day').toISOString(); - case THIS_WEEK: + case 'this_week': return currentTime.endOf('week').toISOString(); - case DATE_AND_TIME: + case 'date_and_time': return expires_at.toISOString(); - case DONT_CLEAR: + case CustomStatusDurationEnum.DONT_CLEAR: default: return ''; } @@ -266,13 +266,18 @@ class CustomStatusModal extends NavigationComponent { handleCustomStatusSuggestionClick = (status: UserCustomStatus) => { const {emoji, text, duration} = status; + if (!duration) { + // This should never happen, but we add a safeguard here + logDebug('clicked on a custom status with no duration'); + return; + } this.setState({emoji, text, duration}); }; handleRecentCustomStatusSuggestionClick = (status: UserCustomStatus) => { const {emoji, text, duration} = status; - this.setState({emoji, text, duration: duration || DONT_CLEAR}); - if (duration === DATE_AND_TIME) { + this.setState({emoji, text, duration: duration || CustomStatusDurationEnum.DONT_CLEAR}); + if (duration === 'date_and_time') { this.openClearAfterModal(); } }; @@ -295,7 +300,7 @@ class CustomStatusModal extends NavigationComponent { handleClearAfterClick = (duration: CustomStatusDuration, expires_at: string) => this.setState({ duration, - expires_at: duration === DATE_AND_TIME && expires_at ? moment(expires_at) : this.state.expires_at, + expires_at: duration === 'date_and_time' && expires_at ? moment(expires_at) : this.state.expires_at, }); openClearAfterModal = async () => { diff --git a/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx b/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx index 8a6b25a859..0b127f89f5 100644 --- a/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx +++ b/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx @@ -9,7 +9,7 @@ import {View, TouchableOpacity} from 'react-native'; import CompassIcon from '@components/compass_icon'; import CustomStatusExpiry from '@components/custom_status/custom_status_expiry'; import CustomStatusText from '@components/custom_status/custom_status_text'; -import {CustomStatusDuration, CST} from '@constants/custom_status'; +import {CST, CustomStatusDurationEnum} from '@constants/custom_status'; import {useTheme} from '@context/theme'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -74,13 +74,13 @@ const ClearAfterMenuItem = ({currentUser, duration, expiryTime = '', handleItemC const style = getStyleSheet(theme); const expiryMenuItems: { [key in CustomStatusDuration]: string } = { - [CustomStatusDuration.DONT_CLEAR]: intl.formatMessage(CST[CustomStatusDuration.DONT_CLEAR]), - [CustomStatusDuration.THIRTY_MINUTES]: intl.formatMessage(CST[CustomStatusDuration.THIRTY_MINUTES]), - [CustomStatusDuration.ONE_HOUR]: intl.formatMessage(CST[CustomStatusDuration.ONE_HOUR]), - [CustomStatusDuration.FOUR_HOURS]: intl.formatMessage(CST[CustomStatusDuration.FOUR_HOURS]), - [CustomStatusDuration.TODAY]: intl.formatMessage(CST[CustomStatusDuration.TODAY]), - [CustomStatusDuration.THIS_WEEK]: intl.formatMessage(CST[CustomStatusDuration.THIS_WEEK]), - [CustomStatusDuration.DATE_AND_TIME]: intl.formatMessage({id: 'custom_status.expiry_dropdown.custom', defaultMessage: 'Custom'}), + [CustomStatusDurationEnum.DONT_CLEAR]: intl.formatMessage(CST[CustomStatusDurationEnum.DONT_CLEAR]), + [CustomStatusDurationEnum.THIRTY_MINUTES]: intl.formatMessage(CST[CustomStatusDurationEnum.THIRTY_MINUTES]), + [CustomStatusDurationEnum.ONE_HOUR]: intl.formatMessage(CST[CustomStatusDurationEnum.ONE_HOUR]), + [CustomStatusDurationEnum.FOUR_HOURS]: intl.formatMessage(CST[CustomStatusDurationEnum.FOUR_HOURS]), + [CustomStatusDurationEnum.TODAY]: intl.formatMessage(CST[CustomStatusDurationEnum.TODAY]), + [CustomStatusDurationEnum.THIS_WEEK]: intl.formatMessage(CST[CustomStatusDurationEnum.THIS_WEEK]), + [CustomStatusDurationEnum.DATE_AND_TIME]: intl.formatMessage({id: 'custom_status.expiry_dropdown.custom', defaultMessage: 'Custom'}), }; const handleClick = preventDoubleTap(() => { diff --git a/app/screens/custom_status_clear_after/index.tsx b/app/screens/custom_status_clear_after/index.tsx index f448ffed70..4f3628a9df 100644 --- a/app/screens/custom_status_clear_after/index.tsx +++ b/app/screens/custom_status_clear_after/index.tsx @@ -15,7 +15,7 @@ import { Options, } from 'react-native-navigation'; -import {CustomStatusDuration} from '@constants/custom_status'; +import {CustomStatusDurationEnum} from '@constants/custom_status'; import {observeCurrentUser} from '@queries/servers/user'; import {dismissModal, popTopScreen} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; @@ -140,7 +140,7 @@ class ClearAfterModal extends NavigationComponent { this.setState({ duration, expiresAt, - showExpiryTime: duration === CustomStatusDuration.DATE_AND_TIME && expiresAt !== '', + showExpiryTime: duration === 'date_and_time' && expiresAt !== '', }); renderClearAfterMenu = () => { @@ -149,7 +149,7 @@ class ClearAfterModal extends NavigationComponent { const {duration} = this.state; - const clearAfterMenu = Object.values(CustomStatusDuration).map( + const clearAfterMenu = Object.values(CustomStatusDurationEnum).map( (item, index, arr) => { if (index === arr.length - 1) { return null; @@ -195,12 +195,12 @@ class ClearAfterModal extends NavigationComponent { diff --git a/app/utils/graphql.ts b/app/utils/graphql.ts new file mode 100644 index 0000000000..bbc31debe8 --- /dev/null +++ b/app/utils/graphql.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +const defaultNotifyProps: UserNotifyProps = { + channel: 'true', + comments: 'never', + desktop: 'mention', + desktop_sound: 'true', + email: 'true', + email_threads: 'all', + first_name: 'false', + mention_keys: '', + push: 'mention', + push_status: 'away', + push_threads: 'all', + +}; +export const gqlToClientUser = (u: Partial): UserProfile => { + return { + id: u.id || '', + create_at: u.createAt || 0, + update_at: u.updateAt || 0, + delete_at: u.deleteAt || 0, + username: u.username || '', + auth_service: u.authService || '', + + email: u.email || '', + email_verified: u.emailVerified ?? true, + nickname: u.nickname || '', + first_name: u.firstName || '', + last_name: u.lastName || '', + position: u.position || '', + roles: u.roles?.map((v) => v.name!).join(' ') || '', + locale: u.locale || '', + notify_props: u.notifyProps || defaultNotifyProps, + props: u.props, + + timezone: u.timezone, + is_bot: u.isBot, + last_picture_update: u.lastPictureUpdate, + remote_id: u.remoteId, + status: u.status?.status || '', + bot_description: u.botDescription, + bot_last_icon_update: u.botLastIconUpdate, + + auth_data: '', + terms_of_service_id: '', + terms_of_service_create_at: 0, + + }; +}; + +export const gqlToClientSession = (s: Partial): Session => { + return { + create_at: s.createAt || 0, + expires_at: s.expiresAt || 0, + id: '', + user_id: '', + }; +}; + +export const gqlToClientTeamMembership = (m: Partial, userId?: string): TeamMembership => { + return { + team_id: m.team?.id || '', + delete_at: m.deleteAt || 0, + roles: m.roles?.map((v) => v.name!).join(' ') || '', + user_id: m.user?.id || userId || '', + scheme_admin: m.schemeAdmin || false, + scheme_user: m.schemeUser || false, + mention_count: 0, + msg_count: 0, + }; +}; + +export const gqlToClientSidebarCategory = (c: Partial, teamId: string): CategoryWithChannels => { + return { + channel_ids: c.channelIds || [], + collapsed: c.collapsed || false, + display_name: c.displayName || '', + id: c.id || '', + muted: c.muted || false, + sort_order: c.sortOrder || 0, + sorting: c.sorting || 'alpha', + team_id: c.teamId || teamId, + type: c.type || 'channels', + }; +}; + +export const gqlToClientTeam = (t: Partial): Team => { + return { + allow_open_invite: t.allowOpenInvite || false, + allowed_domains: t.allowedDomains || '', + company_name: t.companyName || '', + create_at: t.createAt || 0, + delete_at: t.deleteAt || 0, + description: t.description || '', + display_name: t.displayName || '', + email: t.email || '', + group_constrained: t.groupConstrained || false, + id: t.id || '', + invite_id: t.inviteId || '', + last_team_icon_update: t.lastTeamIconUpdate || 0, + name: t.name || '', + scheme_id: t.schemeId || '', + type: t.type || 'I', + update_at: t.updateAt || 0, + + // cloudLimitsArchived and policyId not used + }; +}; + +export const gqlToClientPreference = (p: Partial): PreferenceType => { + return { + category: p.category || '', + name: p.name || '', + user_id: p.userId || '', + value: p.value || '', + }; +}; + +export const gqlToClientChannelMembership = (m: Partial, userId?: string): ChannelMembership => { + return { + channel_id: m.channel?.id || '', + last_update_at: m.lastUpdateAt || 0, + last_viewed_at: m.lastViewedAt || 0, + mention_count: m.mentionCount || 0, + msg_count: m.msgCount || 0, + msg_count_root: m.msgCountRoot || 0, + notify_props: m.notifyProps || {}, + roles: m.roles?.map((r) => r.name).join(' ') || '', + user_id: m.user?.id || userId || '', + is_unread: false, + last_post_at: 0, + post_root_id: '', + scheme_admin: m.schemeAdmin, + scheme_user: m.schemeUser, + }; +}; + +export const gqlToClientChannel = (c: Partial, teamId?: string): Channel => { + return { + create_at: c.createAt || 0, + creator_id: c.creatorId || '', + delete_at: c.deleteAt || 0, + display_name: c.prettyDisplayName || c.displayName || '', + extra_update_at: 0, + group_constrained: c.groupConstrained || false, + header: c.header || '', + id: c.id || '', + last_post_at: c.lastPostAt || 0, + last_root_post_at: c.lastRootPostAt || 0, + name: c.name || '', + purpose: c.purpose || '', + scheme_id: c.schemeId || '', + shared: c.shared || false, + team_id: c.team?.id || teamId || '', + total_msg_count: c.totalMsgCount || 0, + total_msg_count_root: c.totalMsgCountRoot || 0, + type: c.type || 'O', + update_at: c.updateAt || 0, + fake: false, + isCurrent: false, + status: '', + teammate_id: '', + }; +}; + +export const gqlToClientChannelStats = (s: Partial): ChannelStats => { + return { + channel_id: s.id || '', + guest_count: s.stats?.guestCount || 0, + member_count: s.stats?.memberCount || 0, + pinnedpost_count: s.stats?.pinnePostCount || 0, + }; +}; + +export const gqlToClientRole = (r: Partial): Role => { + return { + id: r.id || '', + name: r.name || '', + permissions: r.permissions || [], + built_in: r.builtIn, + scheme_managed: r.schemeManaged, + }; +}; + +export const getMemberChannelsFromGQLQuery = (data: GQLData) => { + return data.channelMembers?.reduce((acc, m) => { + if (m.channel) { + acc.push(gqlToClientChannel(m.channel)); + } + return acc; + }, []); +}; + +export const getMemberTeamsFromGQLQuery = (data: GQLData) => { + return data.teamMembers?.reduce((acc, m) => { + if (m.team) { + acc.push(gqlToClientTeam(m.team)); + } + return acc; + }, []); +}; + +export const filterAndTransformRoles = (roles: Array | undefined>) => { + const byName = roles.reduce<{[name: string]: Partial}>((acum, r) => { + if (r?.name && !acum[r.name]) { + acum[r.name] = r; + } + return acum; + }, {}); + return Object.values(byName).map((r) => gqlToClientRole(r)); +}; diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index e71e239830..c53e427e8d 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -5,7 +5,7 @@ import moment from 'moment-timezone'; import {Alert} from 'react-native'; import {General, Permissions, Preferences} from '@constants'; -import {CustomStatusDuration} from '@constants/custom_status'; +import {CustomStatusDurationEnum} from '@constants/custom_status'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; import {toTitleCase} from '@utils/helpers'; @@ -172,7 +172,7 @@ export function isCustomStatusExpired(user?: UserModel | UserProfile) { return true; } - if (customStatus.duration === CustomStatusDuration.DONT_CLEAR || !customStatus.duration) { + if (customStatus.duration === CustomStatusDurationEnum.DONT_CLEAR || !customStatus.duration) { return false; } @@ -223,7 +223,7 @@ export function confirmOutOfOfficeDisabled(intl: IntlShape, status: string, upda } export function isBot(user: UserProfile | UserModel): boolean { - return 'is_bot' in user ? Boolean(user.is_bot) : Boolean(user.isBot); + return 'isBot' in user ? Boolean(user.isBot) : Boolean(user.is_bot); } export function isShared(user: UserProfile | UserModel): boolean { diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 3639862dc8..314e2c5963 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -118,6 +118,7 @@ interface ClientConfig { ExtendSessionLengthWithActivity: string; FeatureFlagAppsEnabled?: string; FeatureFlagCollapsedThreads?: string; + FeatureFlagGraphQL?: string; GfycatApiKey: string; GfycatApiSecret: string; GoogleDeveloperKey: string; diff --git a/types/api/graphql.d.ts b/types/api/graphql.d.ts new file mode 100644 index 0000000000..98f8811ccb --- /dev/null +++ b/types/api/graphql.d.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type GQLResponse = { + errors?: GQLError[]; + data: GQLData; +} + +type GQLData = { + user?: Partial; + config?: ClientConfig; + license?: ClientLicense; + teamMembers: Array>; + channels?: Array>; + channelsLeft?: Array>; + channelMembers?: Array>; + sidebarCategories?: Array>; +} + +type GQLError = { + message: string; + path: Array; +} + +type GQLUser = { + id: string; + createAt: number; + updateAt: number; + deleteAt: number; + username: string; + authService: string; + email: string; + emailVerified: boolean; + nickname: string; + firstName: string; + lastName: string; + position: string; + locale: string; + notifyProps: UserNotifyProps; + props: UserProps; + timezone: UserTimezone; + isBot: boolean; + lastPictureUpdate: number; + remoteId: string; + botDescription: string; + botLastIconUpdate: number; + + roles: Array>; + customStatus: Partial; + status: Partial; + preferences: Array>; + sessions: Array>; + + // Derived + isSystemAdmin: boolean; + isGuest: boolean; +} + +type GQLSession = { + createAt: number; + expiresAt: number; +} + +type GQLTeamMembership = { + team: Partial; + user: Partial; + roles: Array>; + deleteAt: number; + schemeGuest: boolean; + schemeUser: boolean; + schemeAdmin: boolean; +} + +type GQLSidebarCategory = { + id: string; + sorting: CategorySorting; + type: CategoryType; + displayName: string; + muted: boolean; + collapsed: boolean; + channelIds: string[]; + sortOrder: number; + teamId: string; +} + +type GQLTeam = { + id: string; + displayName: string; + name: string; + description: string; + email: string; + type: TeamType; + companyName: string; + allowedDomains: string; + inviteId: string; + lastTeamIconUpdate: number; + groupConstrained: boolean; + allowOpenInvite: boolean; + updateAt: number; + createAt: number; + deleteAt: number; + schemeId: string; + policyId: string; + cloudLimitsArchived: boolean; +} + +type GQLUserCustomStatus = { + emoji: string; + text: string; + duration: CustomStatusDuration; + expiresAt: string; +} + +type GQLUserStatus = { + status: string; + manual: boolean; + lastActivityAt: number; + activeChannel: string; + dndEndTime: number; +} + +type GQLPreference = { + userId: string; + category: string; + name: string; + value: string; +} + +type GQLChannelMembership = { + channel: Partial; + user: Partial; + roles: Array>; + lastViewedAt: number; + msgCount: number; + msgCountRoot: number; + mentionCount: number; + mentionCountRoot: number; + notifyProps: ChannelNotifyProps; + lastUpdateAt: number; + schemeGuest: boolean; + schemeUser: boolean; + schemeAdmin: boolean; + explicitRoles: string; + cursor: string; +} + +type GQLChannel = { + id: string; + createAt: number; + updateAt: number; + deleteAt: number; + type: ChannelType; + displayName: string; + prettyDisplayName: string; + name: string; + header: string; + purpose: string; + creatorId: string; + schemeId: string; + team: Partial; + cursor: string; + groupConstrained: boolean; + shared: boolean; + lastPostAt: number; + lastRootPostAt: number; + totalMsgCount: number; + totalMsgCountRoot: number; + stats: Partial; +} + +type GQLStats = { + guestCount: number; + memberCount: number; + pinnePostCount: number; +} + +type GQLRole = { + id: string; + name: string; + permissions: string[]; + schemeManaged: boolean; + builtIn: boolean; +} diff --git a/types/api/users.d.ts b/types/api/users.d.ts index af276bcd7a..cfa759c67f 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -11,7 +11,7 @@ type UserNotifyProps = { desktop_sound: 'true' | 'false'; email: 'true' | 'false'; first_name: 'true' | 'false'; - mark_unread: 'all' | 'mention'; + mark_unread?: 'all' | 'mention'; mention_keys: string; push: 'default' | 'all' | 'mention' | 'none'; push_status: 'ooo' | 'offline' | 'away' | 'dnd' | 'online'; @@ -41,8 +41,8 @@ type UserProfile = { terms_of_service_id?: string; terms_of_service_create_at?: number; timezone?: UserTimezone; - is_bot: boolean; - last_picture_update: number; + is_bot?: boolean; + last_picture_update?: number; remote_id?: string; status?: string; bot_description?: string; @@ -96,3 +96,5 @@ type UserCustomStatus = { expires_at?: string; duration?: CustomStatusDuration; }; + +type CustomStatusDuration = '' | 'thirty_minutes' | 'one_hour' | 'four_hours' | 'today' | 'this_week' | 'date_and_time';