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
This commit is contained in:
Daniel Espino García
2022-07-29 16:28:32 +02:00
committed by GitHub
parent 6c5043d598
commit bae5477b35
28 changed files with 1408 additions and 158 deletions

View File

@@ -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 [];
}

View File

@@ -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);

View File

@@ -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<EntryResponse> => {
@@ -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);

View File

@@ -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<Partial<GQLRole>|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};
};

View File

@@ -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<ConfigAndLicenseRequest>, Promise<EntryResponse>] = [
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)};
};

View File

@@ -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};
}
};

View File

@@ -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:

View File

@@ -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,
};

371
app/client/graphQL/entry.ts Normal file
View File

@@ -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
}
}
}
}
`;

View File

@@ -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(

View File

@@ -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);

View File

@@ -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",

4
app/constants/graphql.ts Normal file
View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<string, ChannelModel>, 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<string, ChannelInfoModel>, info) => {
map[info.id] = info;
return map;
@@ -161,10 +164,18 @@ export const prepareDeleteChannel = async (channel: ChannelModel): Promise<Model
return preparedModels;
};
export const queryAllChannels = (database: Database) => {
return database.get<ChannelModel>(CHANNEL).query();
};
export const queryAllChannelsForTeam = (database: Database, teamId: string) => {
return database.get<ChannelModel>(CHANNEL).query(Q.where('team_id', teamId));
};
export const queryAllChannelsInfo = (database: Database) => {
return database.get<ChannelInfoModel>(CHANNEL_INFO).query();
};
export const queryAllChannelsInfoForTeam = (database: Database, teamId: string) => {
return database.get<ChannelInfoModel>(CHANNEL_INFO).query(
Q.on(CHANNEL, Q.where('team_id', teamId)),

View File

@@ -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<Array<Promise<Model[]>>> {
export async function prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData, isCRTEnabled}: PrepareModelsArgs, isGraphQL = false): Promise<Array<Promise<Model[]>>> {
const modelPromises: Array<Promise<Model[]>> = [];
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) {

View File

@@ -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 (
<View style={style.expiryTime}>
<CustomStatusExpiry

View File

@@ -8,12 +8,12 @@ import {TouchableOpacity, View} from 'react-native';
import ClearButton from '@components/custom_status/clear_button';
import CustomStatusText from '@components/custom_status/custom_status_text';
import Emoji from '@components/emoji';
import {CST, CustomStatusDuration} from '@constants/custom_status';
import {CST} from '@constants/custom_status';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
duration: CustomStatusDuration;
duration?: CustomStatusDuration;
emoji?: string;
expires_at?: string;
handleClear?: (status: UserCustomStatus) => 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 && (
<View style={{paddingTop: 5}}>
<CustomStatusText
text={intl.formatMessage(CST[duration])}
text={intl.formatMessage(CST[duration!])}
theme={theme}
textStyle={style.customStatusDuration}
/>

View File

@@ -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) => (
<CustomStatusSuggestion
key={status.text}
handleSuggestionClick={onHandleCustomStatusSuggestionClick} // this.handleCustomStatusSuggestionClick
handleSuggestionClick={onHandleCustomStatusSuggestionClick}
emoji={status.emoji}
text={status.text}
theme={theme}

View File

@@ -16,8 +16,8 @@ import {updateLocalCustomStatus} from '@actions/local/user';
import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import TabletTitle from '@components/tablet_title';
import {CustomStatusDuration, Events, Screens} from '@constants';
import {SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
import {Events, Screens} from '@constants';
import {CustomStatusDurationEnum, SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
import {withServerUrl} from '@context/server';
import {withTheme} from '@context/theme';
import {observeConfig, observeRecentCustomStatus} from '@queries/servers/system';
@@ -25,6 +25,7 @@ import {observeCurrentUser} from '@queries/servers/user';
import {dismissModal, goToScreen, showModal} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {getCurrentMomentForTimezone, getRoundedTime, isCustomStatusExpirySupported} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {mergeNavigationOptions} from '@utils/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -60,8 +61,7 @@ type State = {
expires_at: Moment;
};
const {DONT_CLEAR, THIRTY_MINUTES, ONE_HOUR, FOUR_HOURS, TODAY, THIS_WEEK, DATE_AND_TIME} = CustomStatusDuration;
const DEFAULT_DURATION: CustomStatusDuration = TODAY;
const DEFAULT_DURATION: CustomStatusDuration = 'today';
const BTN_UPDATE_STATUS = 'update-custom-status';
const edges: Edge[] = ['bottom', 'left', 'right'];
@@ -129,12 +129,12 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
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 () => {

View File

@@ -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(() => {

View File

@@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
<View style={style.block}>
<ClearAfterMenuItem
currentUser={currentUser}
duration={CustomStatusDuration.DATE_AND_TIME}
duration={'date_and_time'}
expiryTime={expiresAt}
handleItemClick={this.handleItemClick}
isSelected={duration === CustomStatusDuration.DATE_AND_TIME && expiresAt === ''}
isSelected={duration === 'date_and_time' && expiresAt === ''}
separator={false}
showDateTimePicker={duration === CustomStatusDuration.DATE_AND_TIME}
showDateTimePicker={duration === 'date_and_time'}
showExpiryTime={showExpiryTime}
/>
</View>

213
app/utils/graphql.ts Normal file
View File

@@ -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<GQLUser>): 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<GQLSession>): Session => {
return {
create_at: s.createAt || 0,
expires_at: s.expiresAt || 0,
id: '',
user_id: '',
};
};
export const gqlToClientTeamMembership = (m: Partial<GQLTeamMembership>, 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<GQLSidebarCategory>, 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<GQLTeam>): 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<GQLPreference>): PreferenceType => {
return {
category: p.category || '',
name: p.name || '',
user_id: p.userId || '',
value: p.value || '',
};
};
export const gqlToClientChannelMembership = (m: Partial<GQLChannelMembership>, 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<GQLChannel>, 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<GQLChannel>): 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<GQLRole>): 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<Channel[]>((acc, m) => {
if (m.channel) {
acc.push(gqlToClientChannel(m.channel));
}
return acc;
}, []);
};
export const getMemberTeamsFromGQLQuery = (data: GQLData) => {
return data.teamMembers?.reduce<Team[]>((acc, m) => {
if (m.team) {
acc.push(gqlToClientTeam(m.team));
}
return acc;
}, []);
};
export const filterAndTransformRoles = (roles: Array<Partial<GQLRole> | undefined>) => {
const byName = roles.reduce<{[name: string]: Partial<GQLRole>}>((acum, r) => {
if (r?.name && !acum[r.name]) {
acum[r.name] = r;
}
return acum;
}, {});
return Object.values(byName).map((r) => gqlToClientRole(r));
};

View File

@@ -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 {

View File

@@ -118,6 +118,7 @@ interface ClientConfig {
ExtendSessionLengthWithActivity: string;
FeatureFlagAppsEnabled?: string;
FeatureFlagCollapsedThreads?: string;
FeatureFlagGraphQL?: string;
GfycatApiKey: string;
GfycatApiSecret: string;
GoogleDeveloperKey: string;

183
types/api/graphql.d.ts vendored Normal file
View File

@@ -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<GQLUser>;
config?: ClientConfig;
license?: ClientLicense;
teamMembers: Array<Partial<GQLTeamMembership>>;
channels?: Array<Partial<GQLChannel>>;
channelsLeft?: Array<Partial<GQLChannel>>;
channelMembers?: Array<Partial<GQLChannelMembership>>;
sidebarCategories?: Array<Partial<GQLSidebarCategory>>;
}
type GQLError = {
message: string;
path: Array<string | number>;
}
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<Partial<GQLRole>>;
customStatus: Partial<GQLUserCustomStatus>;
status: Partial<GQLUserStatus>;
preferences: Array<Partial<GQLPreference>>;
sessions: Array<Partial<GQLSession>>;
// Derived
isSystemAdmin: boolean;
isGuest: boolean;
}
type GQLSession = {
createAt: number;
expiresAt: number;
}
type GQLTeamMembership = {
team: Partial<GQLTeam>;
user: Partial<GQLUser>;
roles: Array<Partial<GQLRole>>;
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<GQLChannel>;
user: Partial<GQLUser>;
roles: Array<Partial<GQLRole>>;
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<GQLTeam>;
cursor: string;
groupConstrained: boolean;
shared: boolean;
lastPostAt: number;
lastRootPostAt: number;
totalMsgCount: number;
totalMsgCountRoot: number;
stats: Partial<GQLStats>;
}
type GQLStats = {
guestCount: number;
memberCount: number;
pinnePostCount: number;
}
type GQLRole = {
id: string;
name: string;
permissions: string[];
schemeManaged: boolean;
builtIn: boolean;
}

View File

@@ -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';