[Gekidou] Login entry point (#5568)

* Login entry point

* feedback review

* sort imports

* Fix model relations

* Handle when no current team or current channel has been selected

* Fix MFA unit test

* update prepareCommonSystemValues arguments
This commit is contained in:
Elias Nahum
2021-07-26 04:03:43 -04:00
committed by GitHub
parent 64c11580fc
commit c452ef8038
77 changed files with 13087 additions and 5745 deletions

View File

@@ -29,6 +29,7 @@
"no-shadow": "off",
"react/display-name": [2, { "ignoreTranspilerName": false }],
"react/jsx-filename-extension": 0,
"react-hooks/exhaustive-deps": 0,
"camelcase": [
0,
{

View File

@@ -136,7 +136,6 @@ android {
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
versionCode 333
versionName "2.0.0"
multiDexEnabled = true
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View File

@@ -2,25 +2,14 @@
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {IntlShape} from 'react-intl';
import DatabaseManager from '@database/manager';
import {getSessions} from '@actions/remote/session';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import PushNotifications from '@init/push_notifications';
import {queryCommonSystemValues} from '@app/queries/servers/system';
import {getSessions} from '@actions/remote/user';
const sortByNewest = (a: Session, b: Session) => {
if (a.create_at > b.create_at) {
return -1;
}
return 1;
};
export const scheduleExpiredNotification = async (serverUrl: string, intl: IntlShape) => {
const database = DatabaseManager.serverDatabases[serverUrl].database;
const {currentUserId, config}: {currentUserId: string; config: Partial<ClientConfig>} = await queryCommonSystemValues(database);
import {sortByNewest} from '@utils/general';
import {createIntl} from 'react-intl';
export const scheduleExpiredNotification = async (serverUrl: string, config: Partial<ClientConfig>, userId: string, locale = DEFAULT_LOCALE) => {
if (config.ExtendSessionLengthWithActivity === 'true') {
PushNotifications.cancelAllLocalNotifications();
return null;
@@ -31,7 +20,7 @@ export const scheduleExpiredNotification = async (serverUrl: string, intl: IntlS
let sessions: Session[]|undefined;
try {
sessions = await getSessions(serverUrl, currentUserId);
sessions = await getSessions(serverUrl, userId);
} catch (e) {
// eslint-disable-next-line no-console
console.warn('Failed to get user sessions', e);
@@ -43,23 +32,19 @@ export const scheduleExpiredNotification = async (serverUrl: string, intl: IntlS
}
const session = sessions.sort(sortByNewest)[0];
const expiresAt = session?.expires_at ? parseInt(session.expires_at as string, 10) : 0;
const expiresAt = session?.expires_at || 0;
const expiresInDays = Math.ceil(Math.abs(moment.duration(moment().diff(moment(expiresAt))).asDays()));
const message = intl.formatMessage(
{
id: 'mobile.session_expired',
defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.'},
{
siteName: config.SiteName,
daysCount: expiresInDays,
},
);
const intl = createIntl({locale, messages: getTranslations(locale)});
const body = intl.formatMessage({
id: 'mobile.session_expired',
defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.',
}, {siteName: config.SiteName, daysCount: expiresInDays});
if (expiresAt) {
if (expiresAt && body) {
//@ts-expect-error: Does not need to set all Notification properties
PushNotifications.scheduleNotification({
fireDate: expiresAt,
body: message,
body,
payload: {
userInfo: {
local: true,

View File

@@ -23,7 +23,7 @@ export const autoUpdateTimezone = async (serverUrl: string, {deviceTimezone, use
return {error: `No database present for ${serverUrl}`};
}
const currentUser = await queryUserById({userId, database}) ?? null;
const currentUser = await queryUserById(database, userId) ?? null;
if (!currentUser) {
return null;

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam} from '@queries/servers/channel';
import {displayGroupMessageName, displayUsername} from '@utils/user';
import type {Model} from '@nozbe/watermelondb';
import {forceLogoutIfNecessary} from './session';
import {fetchProfilesPerChannels} from './user';
export type MyChannelsRequest = {
channels?: Channel[];
memberships?: ChannelMembership[];
error?: never;
}
export const fetchMyChannelsForTeam = async (serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false): Promise<MyChannelsRequest> => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
let [channels, memberships] = await Promise.all<Channel[], ChannelMembership[]>([
client.getMyChannels(teamId, includeDeleted, since),
client.getMyChannelMembers(teamId),
]);
if (excludeDirect) {
channels = channels.filter((c) => c.type !== General.GM_CHANNEL && c.type !== General.DM_CHANNEL);
}
const channelIds = new Set<string>(channels.map((c) => c.id));
memberships = memberships.reduce((result: ChannelMembership[], m: ChannelMembership) => {
if (channelIds.has(m.channel_id)) {
result.push(m);
}
return result;
}, []);
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
const modelPromises: Array<Promise<Model[]>> = [];
if (operator) {
const prepare = await prepareMyChannelsForTeam(operator, teamId, channels, memberships);
if (prepare) {
modelPromises.push(...prepare);
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
const flattenedModels = models.flat() as Model[];
if (flattenedModels?.length > 0) {
try {
await operator.batchRecords(flattenedModels);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANNELS');
}
}
}
}
}
return {channels, memberships};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, exludeUserId?: string) => {
const channelIds = directChannels.map((dc) => dc.id);
const result = await fetchProfilesPerChannels(serverUrl, channelIds, exludeUserId, false);
if (result.error) {
return;
}
const displayNameByChannel: Record<string, string> = {};
if (result.data) {
result.data.forEach((data) => {
if (data.users) {
if (data.users.length > 1) {
displayNameByChannel[data.channelId] = displayGroupMessageName(data.users, locale, teammateDisplayNameSetting, exludeUserId);
} else {
displayNameByChannel[data.channelId] = displayUsername(data.users[0], locale, teammateDisplayNameSetting);
}
}
});
}
directChannels.forEach((c) => {
const displayName = displayNameByChannel[c.id];
if (displayName) {
c.display_name = displayName;
}
});
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
operator.handleChannel({channels: directChannels, prepareRecordsOnly: false});
}
};

214
app/actions/remote/entry.ts Normal file
View File

@@ -0,0 +1,214 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Model} from '@nozbe/watermelondb';
import {scheduleExpiredNotification} from '@actions/local/push_notification';
import {General, Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam} from '@queries/servers/channel';
import {prepareMyPreferences} from '@queries/servers/preference';
import {prepareCommonSystemValues} from '@queries/servers/system';
import {addChannelToTeamHistory, prepareMyTeams} from '@queries/servers/team';
import {selectDefaultChannelForTeam} from '@utils/channel';
import {fetchMissingSidebarInfo, fetchMyChannelsForTeam, MyChannelsRequest} from './channel';
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post';
import {MyPreferencesRequest, fetchMyPreferences} from './preference';
import {fetchRolesIfNeeded} from './role';
import {ConfigAndLicenseRequest, fetchConfigAndLicense} from './systems';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from './team';
import type {Client} from '@client/rest';
type AfterLoginArgs = {
serverUrl: string;
user: UserProfile;
deviceToken?: string;
}
export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) => {
const dt = Date.now();
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
if (deviceToken) {
try {
client.attachDevice(deviceToken);
} catch {
// do nothing, the token could've failed to attach to the session but is not a blocker
}
}
try {
let initialTeam: Team|undefined;
let initialChannel: Channel|undefined;
let myTeams: Team[]|undefined;
// Fetch in parallel server config & license / user preferences / teams / team membership / team unreads
const promises: [Promise<ConfigAndLicenseRequest>, Promise<MyPreferencesRequest>, Promise<MyTeamsRequest>] = [
fetchConfigAndLicense(serverUrl, true),
fetchMyPreferences(serverUrl, true),
fetchMyTeams(serverUrl, true),
];
const [clData, prefData, teamData] = await Promise.all(promises);
let chData: MyChannelsRequest|undefined;
// schedule local push notification if needed
if (clData.config) {
scheduleExpiredNotification(serverUrl, clData.config, user.id, user.locale);
}
// select initial team
if (!clData.error && !prefData.error && !teamData.error) {
const teamOrderPreference = getPreferenceValue(prefData.preferences!, Preferences.TEAMS_ORDER, '', '') as string;
const teamRoles: string[] = [];
const teamMembers: string[] = [];
teamData.memberships?.forEach((tm) => {
teamRoles.push(...tm.roles.split(' '));
teamMembers.push(tm.team_id);
});
myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id));
initialTeam = selectDefaultTeam(myTeams, user.locale, teamOrderPreference, clData.config?.ExperimentalPrimaryTeam);
if (initialTeam) {
const rolesToFetch = new Set<string>([...user.roles.split(' '), ...teamRoles]);
// fetch channels / channel membership for initial team
chData = await fetchMyChannelsForTeam(serverUrl, initialTeam.id, false, 0, true);
if (chData.channels?.length && chData.memberships?.length) {
const {channels, memberships} = chData;
const channelIds = new Set(channels?.map((c) => c.id));
for (let i = 0; i < memberships!.length; i++) {
const member = memberships[i];
if (channelIds.has(member.channel_id)) {
member.roles.split(' ').forEach(rolesToFetch.add, rolesToFetch);
}
}
// fetch user roles
const rData = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch));
// select initial channel
initialChannel = selectDefaultChannelForTeam(channels!, memberships!, initialTeam!.id, rData.roles, user.locale);
}
}
}
const modelPromises: Array<Promise<Model[]>> = [];
const {operator} = DatabaseManager.serverDatabases[serverUrl];
if (prefData.preferences) {
const prefModel = prepareMyPreferences(operator, prefData.preferences!);
if (prefModel) {
modelPromises.push(prefModel);
}
}
if (teamData.teams) {
const teamModels = prepareMyTeams(operator, teamData.teams!, teamData.memberships!, teamData.unreads!);
if (teamModels) {
modelPromises.push(...teamModels);
}
}
if (chData?.channels?.length) {
const channelModels = await prepareMyChannelsForTeam(operator, initialTeam!.id, chData.channels, chData.memberships!);
if (channelModels) {
modelPromises.push(...channelModels);
}
}
const systemModels = prepareCommonSystemValues(
operator,
{
config: clData.config || ({} as ClientConfig),
license: clData.license || ({} as ClientLicense),
currentTeamId: initialTeam?.id || '',
currentChannelId: initialChannel?.id || '',
},
);
if (systemModels) {
modelPromises.push(systemModels);
}
if (initialTeam && initialChannel) {
try {
const tch = addChannelToTeamHistory(operator, initialTeam.id, initialChannel.id, true);
modelPromises.push(tch);
} catch {
// do nothing
}
}
const models = await Promise.all(modelPromises);
if (models.length) {
await operator.batchRecords(models.flat() as Model[]);
}
deferredLoginActions(serverUrl, user, prefData, clData, teamData, chData, initialTeam, initialChannel);
const error = clData.error || prefData.error || teamData.error || chData?.error;
return {error, time: Date.now() - dt, hasTeams: Boolean((myTeams?.length || 0) > 0 && !teamData.error)};
} catch (error) {
const {operator} = DatabaseManager.serverDatabases[serverUrl];
const systemModels = await prepareCommonSystemValues(operator, {
config: ({} as ClientConfig),
license: ({} as ClientLicense),
currentTeamId: '',
currentChannelId: '',
});
if (systemModels) {
await operator.batchRecords(systemModels);
}
return {error};
}
};
const deferredLoginActions = async (
serverUrl: string, user: UserProfile, prefData: MyPreferencesRequest, clData: ConfigAndLicenseRequest, teamData: MyTeamsRequest,
chData?: MyChannelsRequest, initialTeam?: Team, initialChannel?: Channel) => {
// defer fetching posts for initial channel
if (initialChannel) {
fetchPostsForChannel(serverUrl, initialChannel.id);
}
// defer sidebar DM & GM profiles
if (chData?.channels?.length && chData.memberships?.length) {
const directChannels = chData.channels.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(prefData.preferences || [], clData.config, clData.license);
await fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), user.locale, teammateDisplayNameSetting, user.id);
}
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannel?.id);
}
// defer groups for team
// if (initialTeam) {
// await fetchGroupsForTeam(serverUrl, initialTeam.id);
// }
// defer fetch channels and unread posts for other teams
if (teamData.teams?.length && teamData.memberships?.length) {
fetchTeamsChannelsAndUnreadPosts(serverUrl, teamData.teams, teamData.memberships, initialTeam?.id);
}
};

View File

@@ -1,17 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter} from 'react-native';
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryCurrentUserId} from '@queries/servers/system';
import type {ClientResponse} from '@mattermost/react-native-network-client';
import type {Client4Error} from '@typings/api/client';
const HTTP_UNAUTHORIZED = 401;
export const doPing = async (serverUrl: string) => {
const client = await NetworkManager.createClient(serverUrl);
@@ -49,32 +41,3 @@ export const doPing = async (serverUrl: string) => {
return {error: undefined};
};
export const forceLogoutIfNecessary = async (serverUrl: string, err: Client4Error) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const currentUserId = await queryCurrentUserId(database);
if ('status_code' in err && err.status_code === HTTP_UNAUTHORIZED && err?.url?.indexOf('/login') === -1 && currentUserId) {
await logout(serverUrl);
}
return {error: null};
};
export const logout = async (serverUrl: string, skipServerLogout = false) => {
if (!skipServerLogout) {
try {
const client = NetworkManager.getClient(serverUrl);
await client.logout();
} catch (error) {
// We want to log the user even if logging out from the server failed
// eslint-disable-next-line no-console
console.warn('An error ocurred loging out from the server', serverUrl, error);
}
}
DeviceEventEmitter.emit(General.SERVER_LOGOUT, serverUrl);
};

227
app/actions/remote/post.ts Normal file
View File

@@ -0,0 +1,227 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionType, General} from '@constants';
import DatabaseManager from '@database/manager';
import {getNeededAtMentionedUsernames} from '@helpers/api/user';
import NetworkManager from '@init/network_manager';
import {queryRecentPostsInChannel} from '@queries/servers/post';
import {queryCurrentUserId, queryCurrentChannelId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user';
import type {Client} from '@client/rest';
import {forceLogoutIfNecessary} from './session';
type PostsRequest = {
error?: never;
order?: string[];
posts?: Post[];
previousPostId?: string;
}
type AuthorsRequest = {
authors?: UserProfile[];
error?: unknown;
}
export const fetchPostsForCurrentChannel = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const currentChannelId = await queryCurrentChannelId(database);
return fetchPostsForChannel(serverUrl, currentChannelId);
};
export const fetchPostsForChannel = async (serverUrl: string, channelId: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let postAction: Promise<PostsRequest>|undefined;
let actionType: string|undefined;
const postsInChannel = await queryRecentPostsInChannel(operator.database, channelId);
if (!postsInChannel || postsInChannel.length < General.POST_CHUNK_SIZE) {
postAction = fetchPosts(serverUrl, channelId, 0, General.POST_CHUNK_SIZE, true);
actionType = ActionType.POSTS.RECEIVED_IN_CHANNEL;
} else {
const since = postsInChannel[0]?.createAt || 0;
postAction = fetchPostsSince(serverUrl, channelId, since, true);
actionType = ActionType.POSTS.RECEIVED_SINCE;
}
const data = await postAction;
if (data.error) {
// Here we should emit an event that fetching posts failed.
}
if (data.posts?.length && data.order?.length) {
try {
await fetchPostAuthors(serverUrl, data.posts, false);
} catch (error) {
// eslint-disable-next-line no-console
console.log('FETCH AUTHORS ERROR', error);
}
operator.handlePosts({
actionType,
order: data.order,
posts: data.posts,
previousPostId: data.previousPostId,
});
}
return {posts: data.posts};
};
export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
for (const member of memberships) {
const channel = channels.find((c) => c.id === member.channel_id);
if (channel && (channel.total_msg_count - member.msg_count) > 0 && channel.id !== excludeChannelId) {
fetchPostsForChannel(serverUrl, channel.id);
}
}
} catch (error) {
return {error};
}
return {error: undefined};
};
export const fetchPosts = async (serverUrl: string, channelId: string, page = 0, perPage = General.POST_CHUNK_SIZE, fetchOnly = false): Promise<PostsRequest> => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const data = await client.getPosts(channelId, page, perPage);
return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_IN_CHANNEL, data, fetchOnly);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchPostsSince = async (serverUrl: string, channelId: string, since: number, fetchOnly = false): Promise<PostsRequest> => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const data = await client.getPostsSince(channelId, since);
return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_SINCE, data, fetchOnly);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOnly = false): Promise<AuthorsRequest> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
const currentUserId = await queryCurrentUserId(operator.database);
const users = await queryAllUsers(operator.database);
const existingUserIds = new Set<string>();
const existingUserNames = new Set<string>();
let excludeUsername;
users.forEach((u) => {
existingUserIds.add(u.id);
existingUserNames.add(u.username);
if (u.id === currentUserId) {
excludeUsername = u.username;
}
});
const usernamesToLoad = getNeededAtMentionedUsernames(existingUserNames, posts, excludeUsername);
const userIdsToLoad = new Set<string>();
posts.forEach((p) => {
const userId = p.user_id;
if (userId === currentUserId) {
return;
}
if (!existingUserIds.has(userId)) {
userIdsToLoad.add(userId);
}
});
try {
const promises: Array<Promise<UserProfile[]>> = [];
if (userIdsToLoad.size) {
promises.push(client.getProfilesByIds(Array.from(userIdsToLoad)));
}
if (usernamesToLoad.size) {
promises.push(client.getProfilesByUsernames(Array.from(usernamesToLoad)));
}
if (promises.length) {
const result = await Promise.all(promises);
const authors = result.flat();
if (!fetchOnly && authors.length) {
operator.handleUsers({
users: authors,
prepareRecordsOnly: true,
});
}
return {authors};
}
return {authors: [] as UserProfile[]};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
const processPostsFetched = (serverUrl: string, actionType: string, data: {order: string[]; posts: Post[]; prev_post_id?: string}, fetchOnly = false) => {
const order = data.order;
const posts = Object.values(data.posts) as Post[];
const previousPostId = data.prev_post_id;
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
operator.handlePosts({
actionType,
order,
posts,
previousPostId,
});
}
}
return {
posts,
order,
previousPostId,
};
};

View File

@@ -4,7 +4,7 @@
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {forceLogoutIfNecessary} from './general';
import {forceLogoutIfNecessary} from './session';
export type MyPreferencesRequest = {
preferences?: PreferenceType[];

236
app/actions/remote/retry.ts Normal file
View File

@@ -0,0 +1,236 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General, Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam} from '@queries/servers/channel';
import {prepareMyPreferences, queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {prepareCommonSystemValues, queryCommonSystemValues} from '@queries/servers/system';
import {prepareMyTeams} from '@queries/servers/team';
import {queryCurrentUser} from '@queries/servers/user';
import {selectDefaultChannelForTeam} from '@utils/channel';
import {fetchMissingSidebarInfo, fetchMyChannelsForTeam, MyChannelsRequest} from './channel';
import {fetchPostsForChannel} from './post';
import {fetchMyPreferences, MyPreferencesRequest} from './preference';
import {fetchRolesIfNeeded} from './role';
import {ConfigAndLicenseRequest, fetchConfigAndLicense} from './systems';
import {fetchMyTeams, MyTeamsRequest} from './team';
import type {Model} from '@nozbe/watermelondb';
export const retryInitialTeamAndChannel = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
let initialTeam: Team|undefined;
let initialChannel: Channel|undefined;
const user = await queryCurrentUser(database);
if (!user) {
return {error: true};
}
// Fetch in parallel server config & license / user preferences / teams / team membership / team unreads
const promises: [Promise<ConfigAndLicenseRequest>, Promise<MyPreferencesRequest>, Promise<MyTeamsRequest>] = [
fetchConfigAndLicense(serverUrl, true),
fetchMyPreferences(serverUrl, true),
fetchMyTeams(serverUrl, true),
];
const [clData, prefData, teamData] = await Promise.all(promises);
let chData: MyChannelsRequest|undefined;
// select initial team
if (!clData.error && !prefData.error && !teamData.error) {
const teamOrderPreference = getPreferenceValue(prefData.preferences!, Preferences.TEAMS_ORDER, '', '') as string;
const teamRoles: string[] = [];
const teamMembers: string[] = [];
teamData.memberships?.forEach((tm) => {
teamRoles.push(...tm.roles.split(' '));
teamMembers.push(tm.team_id);
});
const myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id));
initialTeam = selectDefaultTeam(myTeams, user.locale, teamOrderPreference, clData.config?.ExperimentalPrimaryTeam);
if (initialTeam) {
const rolesToFetch = new Set<string>([...user.roles.split(' '), ...teamRoles]);
// fetch channels / channel membership for initial team
chData = await fetchMyChannelsForTeam(serverUrl, initialTeam.id, false, 0, true);
if (chData.channels?.length && chData.memberships?.length) {
const {channels, memberships} = chData;
const channelIds = new Set(channels?.map((c) => c.id));
for (let i = 0; i < memberships!.length; i++) {
const member = memberships[i];
if (channelIds.has(member.channel_id)) {
member.roles.split(' ').forEach(rolesToFetch.add, rolesToFetch);
}
}
// fetch user roles
const rData = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch));
// select initial channel
initialChannel = selectDefaultChannelForTeam(channels!, memberships!, initialTeam!.id, rData.roles, user.locale);
}
}
}
if (!initialTeam || !initialChannel) {
return {error: true};
}
const modelPromises: Array<Promise<Model[]>> = [];
const {operator} = DatabaseManager.serverDatabases[serverUrl];
const prefModel = prepareMyPreferences(operator, prefData.preferences!);
if (prefModel) {
modelPromises.push(prefModel);
}
const teamModels = prepareMyTeams(operator, teamData.teams!, teamData.memberships!, teamData.unreads!);
if (teamModels) {
modelPromises.push(...teamModels);
}
const channelModels = await prepareMyChannelsForTeam(operator, initialTeam!.id, chData!.channels!, chData!.memberships!);
if (channelModels) {
modelPromises.push(...channelModels);
}
const systemModels = prepareCommonSystemValues(
operator,
{
config: clData.config!,
license: clData.license!,
currentTeamId: initialTeam?.id,
currentChannelId: initialChannel?.id,
},
);
if (systemModels) {
modelPromises.push(systemModels);
}
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
const directChannels = chData!.channels!.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(prefData.preferences || [], clData.config, clData.license);
fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), user.locale, teammateDisplayNameSetting, user.id);
}
fetchPostsForChannel(serverUrl, initialChannel.id);
return {error: false};
} catch (error) {
return {error: true};
}
};
export const retryInitialChannel = async (serverUrl: string, teamId: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
let initialChannel: Channel|undefined;
const rolesToFetch = new Set<string>();
const user = await queryCurrentUser(database);
if (!user) {
return {error: true};
}
const prefs = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT);
const preferences: PreferenceType[] = prefs.map((p) => ({
category: p.category,
name: p.name,
user_id: p.userId,
value: p.value,
}));
const {config, license} = await queryCommonSystemValues(database);
// fetch channels / channel membership for initial team
const chData = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true);
if (chData.channels?.length && chData.memberships?.length) {
const {channels, memberships} = chData;
const channelIds = new Set(channels?.map((c) => c.id));
for (let i = 0; i < memberships!.length; i++) {
const member = memberships[i];
if (channelIds.has(member.channel_id)) {
member.roles.split(' ').forEach(rolesToFetch.add, rolesToFetch);
}
}
// fetch user roles
const rData = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch));
// select initial channel
initialChannel = selectDefaultChannelForTeam(channels!, memberships!, teamId, rData.roles, user.locale);
}
if (!initialChannel) {
return {error: true};
}
const modelPromises: Array<Promise<Model[]>> = [];
const {operator} = DatabaseManager.serverDatabases[serverUrl];
const channelModels = await prepareMyChannelsForTeam(operator, teamId, chData!.channels!, chData!.memberships!);
if (channelModels) {
modelPromises.push(...channelModels);
}
const systemModels = prepareCommonSystemValues(
operator,
{
currentChannelId: initialChannel?.id,
},
);
if (systemModels) {
modelPromises.push(systemModels);
}
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
const directChannels = chData!.channels!.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license);
fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), user.locale, teammateDisplayNameSetting, user.id);
}
fetchPostsForChannel(serverUrl, initialChannel.id);
return {error: false};
} catch (error) {
return {error: true};
}
};

View File

@@ -5,7 +5,18 @@ import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryRoles} from '@queries/servers/role';
export const loadRolesIfNeeded = async (serverUrl: string, updatedRoles: string[]) => {
import {forceLogoutIfNecessary} from './session';
export type RolesRequest = {
error?: never;
roles?: Role[];
}
export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string[]): Promise<RolesRequest> => {
if (!updatedRoles.length) {
return {roles: []};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
@@ -25,16 +36,23 @@ export const loadRolesIfNeeded = async (serverUrl: string, updatedRoles: string[
return !roleNames.includes(newRole);
});
if (!newRoles.length) {
return {roles: []};
}
try {
const roles = await client.getRolesByNames(newRoles);
await operator.handleRole({
roles,
prepareRecordsOnly: false,
});
if (roles.length) {
await operator.handleRole({
roles,
prepareRecordsOnly: false,
});
}
return {roles};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
return null;
};

View File

@@ -0,0 +1,223 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter} from 'react-native';
import {autoUpdateTimezone, getDeviceTimezone, isTimezoneEnabled} from '@actions/local/timezone';
import {General, Database} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryDeviceToken} from '@queries/app/global';
import {queryCurrentUserId, queryCommonSystemValues} from '@queries/servers/system';
import {getCSRFFromCookie} from '@utils/security';
import type {LoginArgs} from '@typings/database/database';
import {logError} from './error';
import {loginEntry} from './entry';
import {fetchDataRetentionPolicy} from './systems';
const HTTP_UNAUTHORIZED = 401;
export const completeLogin = async (serverUrl: string, user: UserProfile) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const {config, license}: { config: Partial<ClientConfig>; license: Partial<ClientLicense> } = await queryCommonSystemValues(database);
if (!Object.keys(config)?.length || !Object.keys(license)?.length) {
return null;
}
// Set timezone
if (isTimezoneEnabled(config)) {
const timezone = getDeviceTimezone();
await autoUpdateTimezone(serverUrl, {deviceTimezone: timezone, userId: user.id});
}
// Data retention
if (config?.DataRetentionEnableMessageDeletion === 'true' && license?.IsLicensed === 'true' && license?.DataRetention === 'true') {
fetchDataRetentionPolicy(serverUrl);
}
return null;
};
export const forceLogoutIfNecessary = async (serverUrl: string, err: ClientErrorProps) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const currentUserId = await queryCurrentUserId(database);
if ('status_code' in err && err.status_code === HTTP_UNAUTHORIZED && err?.url?.indexOf('/login') === -1 && currentUserId) {
await logout(serverUrl);
}
return {error: null};
};
export const getSessions = async (serverUrl: string, currentUserId: string) => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
return undefined;
}
try {
return await client.getSessions(currentUserId);
} catch (e) {
logError(e);
await forceLogoutIfNecessary(serverUrl, e);
}
return undefined;
};
export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaToken, password}: LoginArgs): Promise<LoginActionResponse> => {
let deviceToken;
let user: UserProfile;
const appDatabase = DatabaseManager.appDatabase?.database;
if (!appDatabase) {
return {error: 'App database not found.', failed: true};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error, failed: true};
}
try {
deviceToken = await queryDeviceToken(appDatabase);
user = await client.login(
loginId,
password,
mfaToken,
deviceToken,
ldapOnly,
);
const server = await DatabaseManager.createServerDatabase({
config: {
dbName: serverUrl,
serverUrl,
},
});
await DatabaseManager.setActiveServerDatabase(serverUrl);
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
await server?.operator.handleSystem({
systems: [{
id: Database.SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: user.id,
}],
prepareRecordsOnly: false,
});
const csrfToken = await getCSRFFromCookie(serverUrl);
client.setCSRFToken(csrfToken);
} catch (error) {
return {error, failed: true};
}
try {
const {error, hasTeams, time} = await loginEntry({serverUrl, user});
completeLogin(serverUrl, user);
return {error, failed: false, hasTeams, time};
} catch (error) {
return {error, failed: false, time: 0};
}
};
export const logout = async (serverUrl: string, skipServerLogout = false) => {
if (!skipServerLogout) {
try {
const client = NetworkManager.getClient(serverUrl);
await client.logout();
} catch (error) {
// We want to log the user even if logging out from the server failed
// eslint-disable-next-line no-console
console.warn('An error ocurred loging out from the server', serverUrl, error);
}
}
DeviceEventEmitter.emit(General.SERVER_LOGOUT, serverUrl);
};
export const sendPasswordResetEmail = async (serverUrl: string, email: string) => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
let response;
try {
response = await client.sendPasswordResetEmail(email);
} catch (e) {
return {
error: e,
};
}
return {
data: response.data,
error: undefined,
};
};
export const ssoLogin = async (serverUrl: string, bearerToken: string, csrfToken: string): Promise<LoginActionResponse> => {
let deviceToken;
let user;
const database = DatabaseManager.appDatabase?.database;
if (!database) {
return {error: 'App database not found', failed: true};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error, failed: true};
}
client.setBearerToken(bearerToken);
client.setCSRFToken(csrfToken);
// Setting up active database for this SSO login flow
try {
const server = await DatabaseManager.createServerDatabase({
config: {
dbName: serverUrl,
serverUrl,
},
});
await DatabaseManager.setActiveServerDatabase(serverUrl);
deviceToken = await queryDeviceToken(database);
user = await client.getMe();
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
await server?.operator.handleSystem({
systems: [{
id: Database.SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: user.id,
}],
prepareRecordsOnly: false,
});
} catch (e) {
return {error: e, failed: true};
}
try {
const {error, hasTeams, time} = await loginEntry({serverUrl, user, deviceToken});
completeLogin(serverUrl, user);
return {error, failed: false, hasTeams, time};
} catch (error) {
return {error, failed: false, time: 0};
}
};

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {logError} from '@actions/remote/error';
import {forceLogoutIfNecessary} from '@actions/remote/general';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials';

View File

@@ -5,9 +5,12 @@ import {Model} from '@nozbe/watermelondb';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryWebSocketLastDisconnected} from '@queries/servers/system';
import {prepareMyTeams} from '@queries/servers/team';
import {forceLogoutIfNecessary} from './general';
import {fetchMyChannelsForTeam} from './channel';
import {fetchPostsForUnreadChannels} from './post';
import {forceLogoutIfNecessary} from './session';
export type MyTeamsRequest = {
teams?: Team[];
@@ -55,3 +58,23 @@ export const fetchMyTeams = async (serverUrl: string, fetchOnly = false): Promis
return {error};
}
};
export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, teams: Team[], memberships: TeamMembership[], excludeTeamId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const myTeams = teams.filter((t) => memberships.find((m) => m.team_id === t.id && t.id !== excludeTeamId));
const since = await queryWebSocketLastDisconnected(database);
for await (const team of myTeams) {
const {channels, memberships: members} = await fetchMyChannelsForTeam(serverUrl, team.id, since > 0, since, false, true);
if (channels?.length && members?.length) {
fetchPostsForUnreadChannels(serverUrl, channels, members);
}
}
return {error: undefined};
};

View File

@@ -1,124 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {autoUpdateTimezone, getDeviceTimezone, isTimezoneEnabled} from '@actions/local/timezone';
import {logError} from '@actions/remote/error';
import {loadRolesIfNeeded} from '@actions/remote/role';
import {fetchDataRetentionPolicy} from '@actions/remote/systems';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {Database} from '@constants';
import DatabaseManager from '@database/manager';
import analytics from '@init/analytics';
import NetworkManager from '@init/network_manager';
import {queryDeviceToken} from '@queries/app/global';
import {queryCommonSystemValues} from '@queries/servers/system';
import {getCSRFFromCookie} from '@utils/security';
import {prepareUsers} from '@queries/servers/user';
import type {Client4Error} from '@typings/api/client';
import type {LoadMeArgs, LoginArgs} from '@typings/database/database';
import type {Client} from '@client/rest';
import type {LoadMeArgs} from '@typings/database/database';
import type RoleModel from '@typings/database/models/servers/role';
import type UserModel from '@typings/database/models/servers/user';
import {forceLogoutIfNecessary} from './general';
import {forceLogoutIfNecessary} from './session';
// import {initAfterLogin} from './init';
type LoadedUser = {
currentUser?: UserProfile;
error?: Client4Error;
export type ProfilesPerChannelRequest = {
data?: ProfilesInChannelRequest[];
error?: never;
}
export const completeLogin = async (serverUrl: string, user: UserProfile) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
export type ProfilesInChannelRequest = {
users?: UserProfile[];
channelId: string;
error?: never;
}
const {config, license}: { config: Partial<ClientConfig>; license: Partial<ClientLicense> } = await queryCommonSystemValues(database);
if (!Object.keys(config)?.length || !Object.keys(license)?.length) {
return null;
}
// Set timezone
if (isTimezoneEnabled(config)) {
const timezone = getDeviceTimezone();
await autoUpdateTimezone(serverUrl, {deviceTimezone: timezone, userId: user.id});
}
// Data retention
if (config?.DataRetentionEnableMessageDeletion === 'true' && license?.IsLicensed === 'true' && license?.DataRetention === 'true') {
fetchDataRetentionPolicy(serverUrl);
}
return null;
};
export const getSessions = async (serverUrl: string, currentUserId: string) => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
return undefined;
}
try {
return await client.getSessions(currentUserId);
} catch (e) {
logError(e);
await forceLogoutIfNecessary(serverUrl, e);
}
return undefined;
};
export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaToken, password}: LoginArgs) => {
let deviceToken;
let user: UserProfile;
const appDatabase = DatabaseManager.appDatabase?.database;
if (!appDatabase) {
return {error: 'App database not found.'};
}
let client;
export const fetchProfilesInChannel = async (serverUrl: string, channelId: string, excludeUserId?: string, fetchOnly = false): Promise<ProfilesInChannelRequest> => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
return {channelId, error};
}
try {
deviceToken = await queryDeviceToken(appDatabase);
user = await client.login(
loginId,
password,
mfaToken,
deviceToken,
ldapOnly,
);
const users = await client.getProfilesInChannel(channelId);
const uniqueUsers = Array.from(new Set(users));
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const prepare = prepareUsers(operator, uniqueUsers.filter((u) => u.id !== excludeUserId));
if (prepare) {
const models = await prepare;
if (models.length) {
await operator.batchRecords(models);
}
}
}
}
await DatabaseManager.createServerDatabase({
config: {
dbName: serverUrl,
serverUrl,
},
});
await DatabaseManager.setActiveServerDatabase(serverUrl);
const csrfToken = await getCSRFFromCookie(serverUrl);
client.setCSRFToken(csrfToken);
} catch (e) {
return {error: e};
return {channelId, users: uniqueUsers};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {channelId, error};
}
};
const result = await loadMe(serverUrl, {user, deviceToken});
export const fetchProfilesPerChannels = async (serverUrl: string, channelIds: string[], excludeUserId?: string, fetchOnly = false): Promise<ProfilesPerChannelRequest> => {
try {
const requests = channelIds.map((id) => fetchProfilesInChannel(serverUrl, id, excludeUserId, true));
const data = await Promise.all(requests);
if (!result?.error) {
await completeLogin(serverUrl, user);
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const users = new Set<UserProfile>();
for (const item of data) {
if (item.users?.length) {
item.users.forEach(users.add, users);
}
}
const prepare = prepareUsers(operator, Array.from(users).filter((u) => u.id !== excludeUserId));
if (prepare) {
const models = await prepare;
if (models.length) {
await operator.batchRecords(models);
}
}
}
}
return {data};
} catch (error) {
return {error};
}
// initAfterLogin({serverUrl, user, deviceToken});
return {error: undefined};
};
export const loadMe = async (serverUrl: string, {deviceToken, user}: LoadMeArgs) => {
@@ -259,74 +226,6 @@ export const loadMe = async (serverUrl: string, {deviceToken, user}: LoadMeArgs)
return {currentUser, error: undefined};
};
export const ssoLogin = async (serverUrl: string, bearerToken: string, csrfToken: string) => {
let deviceToken;
const database = DatabaseManager.appDatabase?.database;
if (!database) {
return {error: 'App database not found'};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
client.setBearerToken(bearerToken);
client.setCSRFToken(csrfToken);
// Setting up active database for this SSO login flow
try {
await DatabaseManager.createServerDatabase({
config: {
dbName: serverUrl,
serverUrl,
},
});
await DatabaseManager.setActiveServerDatabase(serverUrl);
deviceToken = await queryDeviceToken(database);
} catch (e) {
return {error: e};
}
let result;
try {
result = (await loadMe(serverUrl, {deviceToken}) as unknown) as LoadedUser;
if (!result?.error && result?.currentUser) {
await completeLogin(serverUrl, result.currentUser);
}
} catch (e) {
return {error: undefined};
}
return result;
};
export const sendPasswordResetEmail = async (serverUrl: string, email: string) => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
let response;
try {
response = await client.sendPasswordResetEmail(email);
} catch (e) {
return {
error: e,
};
}
return {
data: response.data,
error: undefined,
};
};
export const updateMe = async (serverUrl: string, user: UserModel) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
@@ -349,11 +248,13 @@ export const updateMe = async (serverUrl: string, user: UserModel) => {
return {error: e};
}
operator.handleUsers({prepareRecordsOnly: false, users: [data]});
if (data) {
operator.handleUsers({prepareRecordsOnly: false, users: [data]});
const updatedRoles: string[] = data.roles.split(' ');
if (updatedRoles.length) {
await loadRolesIfNeeded(serverUrl, updatedRoles);
const updatedRoles: string[] = data.roles.split(' ');
if (updatedRoles.length) {
await fetchRolesIfNeeded(serverUrl, updatedRoles);
}
}
return {data};

View File

@@ -15,8 +15,6 @@ import type {
RequestOptions,
} from '@mattermost/react-native-network-client';
import type {ClientOptions} from '@typings/api/client';
import * as ClientConstants from './constants';
import ClientError from './error';

View File

@@ -6,10 +6,10 @@ import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
export interface ClientGroupsMix {
getGroups: (filterAllowReference?: boolean, page?: number, perPage?: number) => Promise<Group[]>;
getGroups: (filterAllowReference?: boolean, page?: number, perPage?: number, since?: number) => Promise<Group[]>;
getGroupsByUserId: (userID: string) => Promise<Group[]>;
getAllGroupsAssociatedToTeam: (teamID: string, filterAllowReference?: boolean) => Promise<Group[]>;
getAllGroupsAssociatedToChannelsInTeam: (teamID: string, filterAllowReference?: boolean) => Promise<Group[]>;
getAllGroupsAssociatedToTeam: (teamID: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; tota_group_count: number}>;
getAllGroupsAssociatedToChannelsInTeam: (teamID: string, filterAllowReference?: boolean) => Promise<{groups: Record<string, Group[]>}>;
getAllGroupsAssociatedToChannel: (channelID: string, filterAllowReference?: boolean) => Promise<Group[]>;
}

View File

@@ -5,13 +5,10 @@ import React from 'react';
import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {ClientError} from '@utils/client_error';
import {makeStyleSheetFromTheme} from '@utils/theme';
export type ClientErrorWithIntl = ClientError & {intl: {values?: Record<string, any>}}
type ErrorProps = {
error: ClientErrorWithIntl | string;
error: Partial<ClientErrorProps> | string;
testID?: string;
textStyle?: StyleProp<ViewStyle> | StyleProp<TextStyle>;
theme: Theme;

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import Svg, {Path} from 'react-native-svg';
type CloudSvgProps = {
color: string;
height: number;
width: number;
}
const CloudSvg = ({color, height, width}: CloudSvgProps) => {
return (
<View
style={{height, width, alignItems: 'flex-start'}}
testID='failed_network_action.cloud_icon'
>
<Svg
width={width}
height={height}
viewBox='0 0 72 47'
>
<Path
d='M58.464,19.072c0,-5.181 -1.773,-9.599 -5.316,-13.249c-3.545,-3.649 -7.854,-5.474 -12.932,-5.474c-3.597,0 -6.902,0.979 -9.917,2.935c-3.014,1.959 -5.263,4.523 -6.743,7.696c-1.483,-0.739 -2.856,-1.111 -4.126,-1.111c-2.328,0 -4.363,0.769 -6.109,2.301c-1.745,1.535 -2.831,3.466 -3.252,5.792c-2.856,0.952 -5.185,2.672 -6.982,5.156c-1.8,2.487 -2.697,5.316 -2.697,8.489c0,3.915 1.4,7.299 4.204,10.155c2.802,2.857 6.161,4.285 10.076,4.285l43.794,0c3.595,0 6.664,-1.295 9.203,-3.888c2.538,-2.591 3.808,-5.685 3.808,-9.282c0,-3.702 -1.27,-6.848 -3.808,-9.441c-2.539,-2.591 -5.608,-3.888 -9.203,-3.888l0,-0.476Zm-31.294,16.424l17.17,0c-0.842,-1.62 -2.02,-2.92 -3.535,-3.898c-1.515,-0.977 -3.198,-1.467 -5.05,-1.467c-1.852,0 -3.535,0.49 -5.05,1.467c-1.515,0.978 -2.693,2.278 -3.535,3.898l0,0Zm17.338,-12.407c0,-0.782 -0.252,-1.411 -0.757,-1.886c-0.505,-0.474 -1.124,-0.713 -1.852,-0.713c-0.73,0 -1.347,0.239 -1.852,0.713c-0.505,0.475 -0.757,1.104 -0.757,1.886c0,0.783 0.252,1.412 0.757,1.886c0.505,0.476 1.122,0.713 1.852,0.713c0.728,0 1.347,-0.237 1.852,-0.713c0.505,-0.474 0.757,-1.103 0.757,-1.886Zm-12.288,0c0,-0.782 -0.253,-1.411 -0.758,-1.886c-0.505,-0.474 -1.123,-0.713 -1.851,-0.713c-0.73,0 -1.347,0.239 -1.852,0.713c-0.505,0.475 -0.757,1.104 -0.757,1.886c0,0.783 0.252,1.412 0.757,1.886c0.505,0.476 1.122,0.713 1.852,0.713c0.728,0 1.346,-0.237 1.851,-0.713c0.505,-0.474 0.758,-1.103 0.758,-1.886Z'
fillRule='evenodd'
strokeLinejoin='round'
fill={color}
/>
</Svg>
</View>
);
};
export default CloudSvg;

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import Button from 'react-native-button';
import {View as ViewConstants} from '@constants';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import CloudSvg from './cloud_svg';
type FailedActionProps = {
action?: string;
message: string;
title: string;
onAction: () => void;
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
paddingHorizontal: 20,
paddingVertical: ViewConstants.INDICATOR_BAR_HEIGHT,
paddingBottom: 15,
},
title: {
color: changeOpacity(theme.centerChannelColor, 0.8),
fontSize: 20,
fontWeight: '600',
marginBottom: 15,
marginTop: 10,
},
description: {
color: changeOpacity(theme.centerChannelColor, 0.4),
fontSize: 17,
lineHeight: 25,
textAlign: 'center',
},
link: {
color: theme.buttonColor,
fontSize: 15,
},
buttonContainer: {
backgroundColor: theme.buttonBg,
borderRadius: 5,
height: 42,
justifyContent: 'center',
marginTop: 20,
paddingHorizontal: 12,
},
};
});
const FailedAction = ({action, message, title, onAction}: FailedActionProps) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const text = action || intl.formatMessage({id: 'failed_action.try_again', defaultMessage: 'Try again'});
return (
<View style={style.container}>
<CloudSvg
color={changeOpacity(theme.centerChannelColor, 0.15)}
height={76}
width={76}
/>
<Text
style={style.title}
testID='error_title'
>
{title}
</Text>
<Text
style={style.description}
testID='error_text'
>
{message}
</Text>
<Button
containerStyle={style.buttonContainer}
onPress={onAction}
>
<Text style={style.link}>{text}</Text>
</Button>
</View>
);
};
export default FailedAction;

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {View} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {isMinimumServerVersion} from '@utils/helpers';
import {unsupportedServer} from '@utils/supported_server/supported_server';
import {isSystemAdmin} from '@utils/user';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
type WithUserArgs = WithDatabaseArgs & {
currentUserId: SystemModel;
}
type ServerVersionProps = WithDatabaseArgs & {
config: SystemModel;
user: UserModel;
};
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
const ServerVersion = ({config, user}: ServerVersionProps) => {
const intl = useIntl();
useEffect(() => {
const serverVersion = (config.value?.Version) || '';
if (serverVersion) {
const {RequiredServer: {MAJOR_VERSION, MIN_VERSION, PATCH_VERSION}} = View;
const isSupportedServer = isMinimumServerVersion(serverVersion, MAJOR_VERSION, MIN_VERSION, PATCH_VERSION);
if (!isSupportedServer) {
// Only display the Alert if the TOS does not need to show first
unsupportedServer(isSystemAdmin(user.roles), intl.formatMessage);
}
}
}, [config.value?.Version, user.roles]);
return null;
};
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUserId: database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID),
config: database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG),
}));
const withUser = withObservables(['currentUserId'], ({currentUserId, database}: WithUserArgs) => ({
user: database.collections.get(USER).findAndObserve(currentUserId.value),
}));
export default withDatabase(withSystem(withUser(ServerVersion)));

View File

@@ -54,6 +54,7 @@ export const SYSTEM_IDENTIFIERS = {
CURRENT_USER_ID: 'currentUserId',
DATA_RETENTION_POLICIES: 'dataRetentionPolicies',
LICENSE: 'license',
WEBSOCKET: 'WebSocket',
};
export const GLOBAL_IDENTIFIERS = {

View File

@@ -131,4 +131,5 @@ export default {
NotificationLevels,
SidebarSectionTypes,
IOS_HORIZONTAL_LANDSCAPE: 44,
INDICATOR_BAR_HEIGHT,
};

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, Query, Relation} from '@nozbe/watermelondb';
import {children, field, immutableRelation, lazy} from '@nozbe/watermelondb/decorators';
import {Relation} from '@nozbe/watermelondb';
import {children, field, immutableRelation} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
@@ -42,9 +42,6 @@ export default class ChannelModel extends Model {
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A CHANNEL is associated with only one CHANNEL_INFO (relationship is 1:1) */
[CHANNEL_INFO]: {type: 'has_many', foreignKey: 'channel_id'},
/** A CHANNEL can be associated with multiple CHANNEL_MEMBERSHIP (relationship is 1:N) */
[CHANNEL_MEMBERSHIP]: {type: 'has_many', foreignKey: 'channel_id'},
@@ -54,12 +51,6 @@ export default class ChannelModel extends Model {
/** A CHANNEL can be associated with multiple GROUPS_IN_CHANNEL (relationship is 1:N) */
[GROUPS_IN_CHANNEL]: {type: 'has_many', foreignKey: 'channel_id'},
/** A CHANNEL is associated with only one MY_CHANNEL (relationship is 1:1) */
[MY_CHANNEL]: {type: 'has_many', foreignKey: 'channel_id'},
/** A CHANNEL is associated to only one MY_CHANNEL_SETTINGS (relationship is 1:1) */
[MY_CHANNEL_SETTINGS]: {type: 'has_many', foreignKey: 'channel_id'},
/** A CHANNEL can be associated with multiple POSTS_IN_CHANNEL (relationship is 1:N) */
[POSTS_IN_CHANNEL]: {type: 'has_many', foreignKey: 'id'},
@@ -125,11 +116,12 @@ export default class ChannelModel extends Model {
@immutableRelation(USER, 'creator_id') creator!: Relation<UserModel>;
/** info : Query returning extra information about this channel from CHANNEL_INFO table */
@lazy info = this.collections.get(CHANNEL_INFO).query(Q.on(CHANNEL, 'id', this.id)) as Query<ChannelInfoModel>;
// @lazy info = this.collections.get(CHANNEL_INFO).query(Q.on(CHANNEL, 'id', this.id)) as Query<ChannelInfoModel>;
@immutableRelation(CHANNEL_INFO, 'id') info!: Relation<ChannelInfoModel>;
/** membership : Query returning the membership data for the current user if it belongs to this channel */
@lazy membership = this.collections.get(MY_CHANNEL).query(Q.on(CHANNEL, 'id', this.id)) as Query<MyChannelModel>;
@immutableRelation(MY_CHANNEL, 'id') membership!: Relation<MyChannelModel>;
/** settings: User specific settings/preferences for this channel */
@lazy settings = this.collections.get(MY_CHANNEL_SETTINGS).query(Q.on(CHANNEL, 'id', this.id)) as Query<MyChannelSettingsModel>;
@immutableRelation(MY_CHANNEL_SETTINGS, 'id') settings!: Relation<MyChannelSettingsModel>;
}

View File

@@ -3,7 +3,7 @@
import {Relation} from '@nozbe/watermelondb';
import {field, immutableRelation} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
@@ -20,13 +20,6 @@ export default class ChannelInfoModel extends Model {
/** table (name) : ChannelInfo */
static table = CHANNEL_INFO;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A CHANNEL is associated with only one CHANNEL_INFO (relationship is 1:1) */
[CHANNEL]: {type: 'belongs_to', key: 'id'},
};
/** guest_count : The number of guest in this channel */
@field('guest_count') guestCount!: number;

View File

@@ -3,7 +3,7 @@
import {Relation} from '@nozbe/watermelondb';
import {field, immutableRelation} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
@@ -18,13 +18,6 @@ export default class MyChannelModel extends Model {
/** table (name) : MyChannel */
static table = MY_CHANNEL;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A CHANNEL can be associated to only one record from the MY_CHANNEL table (relationship is 1:1) */
[CHANNEL]: {type: 'belongs_to', key: 'id'},
};
/** last_post_at : The timestamp for any last post on this channel */
@field('last_post_at') lastPostAt!: number;

View File

@@ -3,7 +3,7 @@
import {Relation} from '@nozbe/watermelondb';
import {immutableRelation, json} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {safeParseJSON} from '@utils/helpers';
@@ -20,13 +20,6 @@ export default class MyChannelSettingsModel extends Model {
/** table (name) : MyChannelSettings */
static table = MY_CHANNEL_SETTINGS;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A CHANNEL is related to only one MY_CHANNEL_SETTINGS (relationship is 1:1) */
[CHANNEL]: {type: 'belongs_to', key: 'id'},
};
/** notify_props : Configurations with regards to this channel */
@json('notify_props', safeParseJSON) notifyProps!: ChannelNotifyProps;

View File

@@ -3,7 +3,7 @@
import {Relation} from '@nozbe/watermelondb';
import {field, relation} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
@@ -18,13 +18,6 @@ export default class MyTeamModel extends Model {
/** table (name) : MyTeam */
static table = MY_TEAM;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** TEAM and MY_TEAM have a 1:1 relationship. */
[TEAM]: {type: 'belongs_to', key: 'id'},
};
/** is_unread : Boolean flag for unread messages on team level */
@field('is_unread') isUnread!: boolean;
@@ -35,5 +28,5 @@ export default class MyTeamModel extends Model {
@field('roles') roles!: string;
/** team : The relation to the TEAM, that this user belongs to */
@relation(MY_TEAM, 'id') team!: Relation<TeamModel>;
@relation(TEAM, 'id') team!: Relation<TeamModel>;
}

View File

@@ -40,9 +40,6 @@ export default class PostModel extends Model {
/** A POST can have multiple POSTS_IN_THREAD. (relationship is 1:N)*/
[POSTS_IN_THREAD]: {type: 'has_many', foreignKey: 'id'},
/** A POST can have POST_METADATA. (relationship is 1:1)*/
[POST_METADATA]: {type: 'has_many', foreignKey: 'id'},
/** A POST can have multiple REACTION. (relationship is 1:N)*/
[REACTION]: {type: 'has_many', foreignKey: 'post_id'},
@@ -102,7 +99,7 @@ export default class PostModel extends Model {
@children(FILE) files!: FileModel[];
/** metadata: All the extra data associated with this Post */
@children(POST_METADATA) metadata!: PostMetadataModel[];
@immutableRelation(POST_METADATA, 'id') metadata!: Relation<PostMetadataModel>;
/** reactions: All the reactions associated with this Post */
@children(REACTION) reactions!: ReactionModel[];

View File

@@ -3,7 +3,7 @@
import {Relation} from '@nozbe/watermelondb';
import {immutableRelation, json} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {safeParseJSON} from '@utils/helpers';
@@ -19,13 +19,6 @@ export default class PostMetadataModel extends Model {
/** table (name) : PostMetadata */
static table = POST_METADATA;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A POST can have multiple POST_METADATA.(relationship is 1:N)*/
[POST]: {type: 'belongs_to', key: 'id'},
};
/** data : Different types of data ranging from embeds to images. */
@json('data', safeParseJSON) data!: PostMetadata;

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, Query} from '@nozbe/watermelondb';
import {children, field, lazy} from '@nozbe/watermelondb/decorators';
import {Relation} from '@nozbe/watermelondb';
import {children, field, immutableRelation} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
@@ -42,15 +42,9 @@ export default class TeamModel extends Model {
/** A TEAM has a 1:N relationship with GROUPS_IN_TEAM. A TEAM can possess multiple groups */
[GROUPS_IN_TEAM]: {type: 'has_many', foreignKey: 'team_id'},
/** A TEAM has a 1:1 relationship with MY_TEAM. */
[MY_TEAM]: {type: 'has_many', foreignKey: 'team_id'},
/** A TEAM has a 1:N relationship with SLASH_COMMAND. A TEAM can possess multiple slash commands */
[SLASH_COMMAND]: {type: 'has_many', foreignKey: 'team_id'},
/** A TEAM has a 1:1 relationship with TEAM_CHANNEL_HISTORY.*/
[TEAM_CHANNEL_HISTORY]: {type: 'has_many', foreignKey: 'team_id'},
/** A TEAM has a 1:N relationship with TEAM_MEMBERSHIP. A TEAM can regroup multiple users */
[TEAM_MEMBERSHIP]: {type: 'has_many', foreignKey: 'team_id'},
@@ -91,14 +85,14 @@ export default class TeamModel extends Model {
/** groupsInTeam : All the groups associated with this team */
@children(GROUPS_IN_TEAM) groupsInTeam!: GroupsInTeamModel[];
/** myTeam : Retrieves additional information about the team that this user is possibly part of. This query might yield no result if the user isn't part of a team. */
@lazy myTeam = this.collections.get(MY_TEAM).query(Q.on(TEAM, 'id', this.id)) as Query<MyTeamModel>;
/** myTeam : Retrieves additional information about the team that this user is possibly part of. */
@immutableRelation(MY_TEAM, 'id') myTeam!: Relation<MyTeamModel>;
/** slashCommands : All the slash commands associated with this team */
@children(SLASH_COMMAND) slashCommands!: SlashCommandModel[];
/** teamChannelHistory : A history of the channels in this team that has been visited, ordered by the most recent and capped to the last 5 */
@lazy teamChannelHistory = this.collections.get(TEAM_CHANNEL_HISTORY).query(Q.on(TEAM, 'id', this.id)) as Query<TeamChannelHistoryModel>;
@immutableRelation(TEAM_CHANNEL_HISTORY, 'id') teamChannelHistory!: Relation<TeamChannelHistoryModel>;
/** members : All the users associated with this team */
@children(TEAM_MEMBERSHIP) members!: TeamMembershipModel[];

View File

@@ -3,7 +3,7 @@
import {Relation} from '@nozbe/watermelondb';
import {immutableRelation, json} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {safeParseJSON} from '@utils/helpers';
@@ -20,13 +20,6 @@ export default class TeamChannelHistoryModel extends Model {
/** table (name) : TeamChannelHistory */
static table = TEAM_CHANNEL_HISTORY;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A TEAM and TEAM_CHANNEL_HISTORY share a 1:1 relationship */
[TEAM]: {type: 'belongs_to', key: 'id'},
};
/** channel_ids : An array containing the last 5 channels visited within this team order by recency */
@json('channel_ids', safeParseJSON) channelIds!: string[];

View File

@@ -4,10 +4,11 @@
import {Q} from '@nozbe/watermelondb';
import {Database} from '@constants';
import {getRawRecordPairs, retrieveRecords} from '@database/operator/utils/general';
import {getRawRecordPairs, getValidRecordsForUpdate, retrieveRecords} from '@database/operator/utils/general';
import {transformPostInThreadRecord} from '@database/operator/server_data_operator/transformers/post';
import {getPostListEdges} from '@database//operator/utils/post';
import type {RecordPair} from '@typings/database/database';
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
export interface PostsInThreadHandlerMix {
@@ -23,7 +24,7 @@ const PostsInThreadHandler = (superclass: any) => class extends superclass {
return [];
}
const update: PostsInThread[] = [];
const update: RecordPair[] = [];
const create: PostsInThread[] = [];
const ids = Object.keys(postsMap);
for await (const rootId of ids) {
@@ -36,11 +37,16 @@ const PostsInThreadHandler = (superclass: any) => class extends superclass {
if (chunks.length) {
const chunk = chunks[0];
update.push({
const newValue = {
id: rootId,
earliest: Math.min(chunk.earliest, firstPost.create_at),
latest: Math.max(chunk.latest, lastPost.create_at),
});
};
update.push(getValidRecordsForUpdate({
tableName: POSTS_IN_THREAD,
newValue,
existingRecord: chunk,
}));
} else {
// create chunk
create.push({
@@ -53,7 +59,7 @@ const PostsInThreadHandler = (superclass: any) => class extends superclass {
const postInThreadRecords = (await this.prepareRecords({
createRaws: getRawRecordPairs(create),
updateRaws: getRawRecordPairs(update),
updateRaws: update,
transformer: transformPostInThreadRecord,
tableName: POSTS_IN_THREAD,
})) as PostsInThreadModel[];

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General, Preferences} from '@constants';
export function getPreferenceValue(preferences: PreferenceType[], category: string, name: string, defaultValue: unknown = '') {
const pref = preferences.find((p) => p.category === category && p.name === name);
@@ -24,3 +26,15 @@ export function getPreferenceAsInt(preferences: PreferenceType[], category: stri
return defaultValue;
}
export function getTeammateNameDisplaySetting(preferences: PreferenceType[], config?: ClientConfig, license?: ClientLicense) {
const useAdminTeammateNameDisplaySetting = license?.LockTeammateNameDisplay === 'true' && config?.LockTeammateNameDisplay === 'true';
const preference = getPreferenceValue(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, '') as string;
if (preference && !useAdminTeammateNameDisplaySetting) {
return preference;
} else if (config?.TeammateNameDisplay) {
return config.TeammateNameDisplay;
}
return General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME;
}

35
app/helpers/api/user.ts Normal file
View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General} from '@constants';
export const getNeededAtMentionedUsernames = (usernames: Set<string>, posts: Post[], excludeUsername?: string) => {
const usernamesToLoad = new Set<string>();
const pattern = /\B@(([a-z0-9_.-]*[a-z0-9_])[.-]*)/gi;
posts.forEach((p) => {
let match;
while ((match = pattern.exec(p.message)) !== null) {
// match[1] is the matched mention including trailing punctuation
// match[2] is the matched mention without trailing punctuation
if (General.SPECIAL_MENTIONS.indexOf(match[2]) !== -1) {
continue;
}
if (match[1] === excludeUsername || match[2] === excludeUsername) {
continue;
}
if (usernames.has(match[1]) || usernames.has(match[2])) {
continue;
}
// If there's no trailing punctuation, this will only add 1 item to the set
usernamesToLoad.add(match[1]);
usernamesToLoad.add(match[2]);
}
});
return usernamesToLoad;
};

View File

@@ -206,18 +206,18 @@ export function getLocaleFromLanguage(lang: string) {
}
export function resetMomentLocale(locale?: string) {
moment.locale(locale || DEFAULT_LOCALE);
moment.locale(locale || DEFAULT_LOCALE.split('-')[0]);
}
export function getTranslations(locale?: string) {
return loadTranslation(locale);
}
export function getLocalizedMessage(lang: string, id: string) {
export function getLocalizedMessage(lang: string, id: string, defaultMessage?: string) {
const locale = getLocaleFromLanguage(lang);
const translations = getTranslations(locale);
return translations[id];
return translations[id] || defaultMessage;
}
export function t(v: string): string {

View File

@@ -147,8 +147,7 @@ class PushNotifications {
if (payload?.server_url) {
const database = DatabaseManager.serverDatabases[payload.server_url]?.database;
const currentChannelId = await queryCurrentChannelId(database);
const channelId = currentChannelId?.value;
const channelId = await queryCurrentChannelId(database);
if (channelId && payload.channel_id !== channelId) {
const screen = 'Notification';

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type ChannelModel from '@typings/database/models/servers/channel';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
const {SERVER: {CHANNEL}} = MM_TABLES;
export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[]) => {
const allChannelsForTeam = await queryAllChannelsForTeam(operator.database, teamId);
const channelInfos: ChannelInfo[] = [];
const memberships = channelMembers.map((cm) => ({...cm, id: cm.channel_id}));
for await (const c of channels) {
const storedChannel = allChannelsForTeam.find((sc) => sc.id === c.id);
let storedInfo: ChannelInfoModel;
let member_count = 0;
let guest_count = 0;
let pinned_post_count = 0;
if (storedChannel) {
storedInfo = (await storedChannel.info.fetch()) as ChannelInfoModel;
member_count = storedInfo.memberCount;
guest_count = storedInfo.guestCount;
pinned_post_count = storedInfo.pinnedPostCount;
}
channelInfos.push({
id: c.id,
header: c.header,
purpose: c.purpose,
guest_count,
member_count,
pinned_post_count,
});
}
try {
const channelRecords = operator.handleChannel({channels, prepareRecordsOnly: true});
const channelInfoRecords = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true});
const membershipRecords = operator.handleChannelMembership({channelMemberships: memberships, prepareRecordsOnly: true});
const myChannelRecords = operator.handleMyChannel({myChannels: memberships, prepareRecordsOnly: true});
const myChannelSettingsRecords = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true});
return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords];
} catch {
return undefined;
}
};
export const queryAllChannelsForTeam = (database: Database, teamId: string) => {
return database.get(CHANNEL).query(Q.where('team_id', teamId)).fetch() as Promise<ChannelModel[]>;
};

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type PostModel from '@typings/database/models/servers/post';
import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel';
const {SERVER: {POST, POSTS_IN_CHANNEL}} = MM_TABLES;
export const queryPostsInChannel = (database: Database, channelId: string): Promise<PostInChannelModel[]> => {
try {
return database.get(POSTS_IN_CHANNEL).query(
Q.where('channel_id', channelId),
Q.experimentalSortBy('latest', Q.desc),
).fetch() as Promise<PostInChannelModel[]>;
} catch {
return Promise.resolve([] as PostInChannelModel[]);
}
};
export const queryPostsChunk = (database: Database, channelId: string, earlies: number, latest: number): Promise<PostModel[]> => {
try {
return database.get(POST).query(
Q.and(
Q.where('channel_id', channelId),
Q.where('create_at', Q.between(earlies, latest)),
Q.where('delete_at', Q.eq(0)),
),
Q.experimentalSortBy('create_at', Q.desc),
).fetch() as Promise<PostModel[]>;
} catch {
return Promise.resolve([] as PostModel[]);
}
};
export const queryRecentPostsInChannel = async (database: Database, channelId: string): Promise<PostModel[]> => {
try {
const chunks = await queryPostsInChannel(database, channelId);
if (chunks.length) {
const recent = chunks[0];
return queryPostsChunk(database, channelId, recent.earliest, recent.latest);
}
return Promise.resolve([] as PostModel[]);
} catch {
return Promise.resolve([] as PostModel[]);
}
};

View File

@@ -1,7 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type PreferenceModel from '@typings/database/models/servers/preference';
export const prepareMyPreferences = (operator: ServerDataOperator, preferences: PreferenceType[]) => {
try {
@@ -13,3 +18,10 @@ export const prepareMyPreferences = (operator: ServerDataOperator, preferences:
return undefined;
}
};
export const queryPreferencesByCategoryAndName = (database: Database, category: string, name: string) => {
return database.get(MM_TABLES.SERVER.PREFERENCE).query(
Q.where('category', category),
Q.where('name', name),
).fetch() as Promise<PreferenceModel[]>;
};

View File

@@ -8,12 +8,20 @@ import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type SystemModel from '@typings/database/models/servers/system';
type PrepareCommonSystemValuesArgs = {
config?: ClientConfig;
currentChannelId?: string;
currentTeamId?: string;
currentUserId?: string;
license?: ClientLicense;
}
const {SERVER: {SYSTEM}} = MM_TABLES;
export const queryCurrentChannelId = async (serverDatabase: Database) => {
try {
const currentChannelId = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID) as SystemModel;
return currentChannelId?.value || '';
return (currentChannelId?.value || '') as string;
} catch {
return '';
}
@@ -22,14 +30,14 @@ export const queryCurrentChannelId = async (serverDatabase: Database) => {
export const queryCurrentUserId = async (serverDatabase: Database) => {
try {
const currentUserId = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.CURRENT_USER_ID) as SystemModel;
return currentUserId?.value || '';
return (currentUserId?.value || '') as string;
} catch {
return '';
}
};
export const queryCommonSystemValues = async (database: Database) => {
const systemRecords = (await database.collections.get(SYSTEM).query().fetch()) as SystemModel[];
export const queryCommonSystemValues = async (serverDatabase: Database) => {
const systemRecords = (await serverDatabase.collections.get(SYSTEM).query().fetch()) as SystemModel[];
let config = {};
let license = {};
let currentChannelId = '';
@@ -50,7 +58,7 @@ export const queryCommonSystemValues = async (database: Database) => {
currentUserId = systemRecord.value;
break;
case SYSTEM_IDENTIFIERS.LICENSE:
license = systemRecord.value;
license = systemRecord.value as ClientLicense;
break;
}
});
@@ -59,38 +67,62 @@ export const queryCommonSystemValues = async (database: Database) => {
currentChannelId,
currentTeamId,
currentUserId,
config,
license,
config: (config as ClientConfig),
license: (license as ClientLicense),
};
};
export const prepareCommonSystemValues = (
operator: ServerDataOperator, config: ClientConfig, license: ClientLicense,
currentUserId: string, currentTeamId: string, currentChannelId: string) => {
export const queryWebSocketLastDisconnected = async (serverDatabase: Database) => {
try {
const websocketLastDisconnected = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.WEBSOCKET) as SystemModel;
return (parseInt(websocketLastDisconnected?.value || 0, 10) || 0);
} catch {
return 0;
}
};
export const prepareCommonSystemValues = (
operator: ServerDataOperator, values: PrepareCommonSystemValuesArgs) => {
try {
const {config, currentChannelId, currentTeamId, currentUserId, license} = values;
const systems: IdValue[] = [];
if (config !== undefined) {
systems.push({
id: SYSTEM_IDENTIFIERS.CONFIG,
value: JSON.stringify(config),
});
}
if (license !== undefined) {
systems.push({
id: SYSTEM_IDENTIFIERS.LICENSE,
value: JSON.stringify(license),
});
}
if (currentUserId !== undefined) {
systems.push({
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: currentUserId,
});
}
if (currentTeamId !== undefined) {
systems.push({
id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
value: currentTeamId,
});
}
if (currentChannelId !== undefined) {
systems.push({
id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID,
value: currentChannelId,
});
}
return operator.handleSystem({
systems: [
{
id: SYSTEM_IDENTIFIERS.CONFIG,
value: JSON.stringify(config),
},
{
id: SYSTEM_IDENTIFIERS.LICENSE,
value: JSON.stringify(license),
},
{
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
value: currentUserId,
},
{
id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
value: currentTeamId,
},
{
id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID,
value: currentChannelId,
},
],
systems,
prepareRecordsOnly: true,
});
} catch {

View File

@@ -1,14 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {Database as DatabaseConstants} from '@constants';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type TeamChannelHistoryModel from '@typings/database/models/servers/team_channel_history';
import type TeamModel from '@typings/database/models/servers/team';
const {TEAM, TEAM_CHANNEL_HISTORY} = DatabaseConstants.MM_TABLES.SERVER;
export const addChannelToTeamHistory = async (operator: ServerDataOperator, teamId: string, channelId: string, prepareRecordsOnly = false) => {
let tch: TeamChannelHistory|undefined;
try {
const teamChannelHistory = (await operator.database.get(TEAM_CHANNEL_HISTORY).find(teamId)) as TeamChannelHistoryModel;
const channelIds = teamChannelHistory.channelIds;
channelIds.unshift(channelId);
tch = {
id: teamId,
channel_ids: channelIds.slice(0, 5),
};
} catch {
tch = {
id: teamId,
channel_ids: [channelId],
};
}
return operator.handleTeamChannelHistory({teamChannelHistories: [tch], prepareRecordsOnly});
};
export const prepareMyTeams = (operator: ServerDataOperator, teams: Team[], memberships: TeamMembership[], unreads: TeamUnread[]) => {
try {
const teamRecords = operator.handleTeam({prepareRecordsOnly: true, teams});
const teamMembershipRecords = operator.handleTeamMemberships({prepareRecordsOnly: true, teamMemberships: memberships});
const teamMemberships = memberships.filter((m) => teams.find((t) => t.id === m.team_id));
const teamMembershipRecords = operator.handleTeamMemberships({prepareRecordsOnly: true, teamMemberships});
const myTeams: MyTeam[] = unreads.map((unread) => {
const matchingTeam = memberships.find((team) => team.team_id === unread.team_id);
const matchingTeam = teamMemberships.find((team) => team.team_id === unread.team_id);
return {id: unread.team_id, roles: matchingTeam?.roles ?? '', is_unread: unread.msg_count > 0, mentions_count: unread.mention_count};
});
const myTeamRecords = operator.handleMyTeam({
@@ -21,3 +51,12 @@ export const prepareMyTeams = (operator: ServerDataOperator, teams: Team[], memb
return undefined;
}
};
export const queryTeamById = async (database: Database, teamId: string): Promise<TeamModel|undefined> => {
try {
const team = (await database.get(TEAM).find(teamId)) as TeamModel;
return team;
} catch {
return undefined;
}
};

View File

@@ -5,13 +5,14 @@ import {Database} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type User from '@typings/database/models/servers/user';
import {queryCurrentUserId} from './system';
export const queryUserById = async ({userId, database}: { userId: string; database: Database}) => {
import type ServerDataOperator from '@database/operator/server_data_operator';
import type UserModel from '@typings/database/models/servers/user';
export const queryUserById = async (database: Database, userId: string) => {
try {
const userRecord = (await database.collections.get(MM_TABLES.SERVER.USER).find(userId)) as User;
const userRecord = (await database.collections.get(MM_TABLES.SERVER.USER).find(userId)) as UserModel;
return userRecord;
} catch {
return undefined;
@@ -21,8 +22,29 @@ export const queryUserById = async ({userId, database}: { userId: string; databa
export const queryCurrentUser = async (database: Database) => {
const currentUserId = await queryCurrentUserId(database);
if (currentUserId) {
return queryUserById({userId: currentUserId, database});
return queryUserById(database, currentUserId);
}
return undefined;
};
export const queryAllUsers = async (database: Database): Promise<UserModel[]> => {
try {
const userRecords = (await database.get(MM_TABLES.SERVER.USER).query().fetch()) as UserModel[];
return userRecords;
} catch {
return Promise.resolve([] as UserModel[]);
}
};
export const prepareUsers = (operator: ServerDataOperator, users: UserProfile[]) => {
try {
if (users.length) {
return operator.handleUsers({users, prepareRecordsOnly: true});
}
return undefined;
} catch {
return undefined;
}
};

View File

@@ -6,14 +6,13 @@ import {Text} from 'react-native';
import FormattedText from '@components/formatted_text';
import {General} from '@constants';
import {t} from '@utils/i18n';
import {makeStyleSheetFromTheme} from '@utils/theme';
type ChannelDisplayNameProps = {
channelType: string;
currentUserId: string;
displayName: string;
teammateId: string;
teammateId?: string;
theme: Theme;
};
@@ -33,7 +32,7 @@ const ChannelDisplayName = ({channelType, currentUserId, displayName, teammateId
>
{isSelfDMChannel ? (
<FormattedText
id={t('channel_header.directchannel.you')}
id={'channel_header.directchannel.you'}
defaultMessage={'{displayname} (you)'}
values={{displayname: displayName}}
/>) : displayName

View File

@@ -1,46 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General} from '@constants';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {isGuest as isTeammateGuest} from '@utils/user';
import React from 'react';
import {TouchableOpacity, View} from 'react-native';
import ChannelIcon from '@components/channel_icon';
import CompassIcon from '@components/compass_icon';
import {MM_TABLES} from '@constants/database';
import {General} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {getUserIdFromChannelName, isGuest as isTeammateGuest} from '@utils/user';
import ChannelDisplayName from './channel_display_name';
import ChannelGuestLabel from './channel_guest_label';
import type {Database} from '@nozbe/watermelondb';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelSettingsModel from '@typings/database/models/servers/my_channel_settings';
import type UserModel from '@typings/database/models/servers/user';
import type SystemModel from '@typings/database/models/servers/system';
type WithChannelArgs = WithDatabaseArgs & {
currentUserId: SystemModel;
channel: ChannelModel;
}
type ChannelTitleInputProps = {
canHaveSubtitle: boolean;
channel: ChannelModel;
currentUserId: string;
teammateId?: string;
onPress: () => void;
};
type ChannelTitleProps = ChannelTitleInputProps & {
channelInfo: ChannelInfoModel;
channelSettings: MyChannelSettingsModel;
database: Database;
teammate?: UserModel;
teammateId: string;
};
const ConnectedChannelTitle = ({
const ChannelTitle = ({
canHaveSubtitle,
channel,
channelInfo,
@@ -48,7 +50,6 @@ const ConnectedChannelTitle = ({
currentUserId,
onPress,
teammate,
teammateId,
}: ChannelTitleProps) => {
const theme = useTheme();
@@ -57,12 +58,11 @@ const ConnectedChannelTitle = ({
const isArchived = channel.deleteAt !== 0;
const isChannelMuted = channelSettings.notifyProps?.mark_unread === 'mention';
const isChannelShared = false; // todo: Read this value from ChannelModel when implemented
const hasGuests = channelInfo.guestCount > 0;
const teammateRoles = teammate?.roles ?? '';
const isGuest = channelType === General.DM_CHANNEL && isTeammateGuest(teammateRoles);
const showGuestLabel = (canHaveSubtitle || (isGuest && hasGuests) || (channelType === General.DM_CHANNEL && isGuest));
const showGuestLabel = (canHaveSubtitle && ((isGuest && hasGuests) || (channelType === General.DM_CHANNEL && isGuest)));
return (
<TouchableOpacity
@@ -81,7 +81,7 @@ const ConnectedChannelTitle = ({
channelType={channelType}
currentUserId={currentUserId}
displayName={channel.displayName}
teammateId={teammateId}
teammateId={teammate?.id}
theme={theme}
/>
{isChannelShared && (
@@ -175,16 +175,21 @@ const getStyle = makeStyleSheetFromTheme((theme) => {
};
});
const ChannelTitle: React.FunctionComponent<ChannelTitleInputProps> =
withDatabase(
withObservables(['channel', 'teammateId'], ({channel, teammateId, database}: { channel: ChannelModel; teammateId: string; database: Database }) => {
return {
channelInfo: database.collections.get(MM_TABLES.SERVER.CHANNEL_INFO).findAndObserve(channel.id),
channelSettings: database.collections.get(MM_TABLES.SERVER.MY_CHANNEL_SETTINGS).findAndObserve(channel.id),
...(teammateId && channel.displayName && {teammate: database.collections.get(MM_TABLES.SERVER.USER).findAndObserve(teammateId)}),
};
},
)(ConnectedChannelTitle),
);
const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUserId: database.collections.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID),
}));
export default ChannelTitle;
const enhancedChannelTitle = withObservables(['channel', 'currentUserId'], ({channel, currentUserId, database}: WithChannelArgs) => {
let teammateId;
if (channel.type === General.DM_CHANNEL && channel.displayName) {
teammateId = getUserIdFromChannelName(currentUserId.value, channel.name);
}
return {
channelInfo: channel.info.observe(),
channelSettings: channel.settings.observe(),
...(teammateId && {teammate: database.collections.get(MM_TABLES.SERVER.USER).findAndObserve(teammateId)}),
};
});
export default withDatabase(withSystemIds(enhancedChannelTitle(ChannelTitle)));

View File

@@ -1,29 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {DeviceEventEmitter, LayoutChangeEvent, Platform, useWindowDimensions, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTheme} from '@context/theme';
import VIEWS from '@constants/view';
import DEVICE from '@constants/device';
import {General} from '@constants';
import {getUserIdFromChannelName} from '@utils/user';
import {Device, View as ViewConstants} from '@constants';
import {MM_TABLES} from '@constants/database';
import {makeStyleSheetFromTheme} from '@utils/theme';
import ChannelTitle from './channel_title';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
type ChannelNavBar = {
channel: ChannelModel;
currentUserId: string;
onPress: () => void;
config: ClientConfig;
}
const ChannelNavBar = ({currentUserId, channel, onPress}: ChannelNavBar) => {
const ChannelNavBar = ({channel, onPress}: ChannelNavBar) => {
const insets = useSafeAreaInsets();
const theme = useTheme();
const style = getStyleFromTheme(theme);
@@ -40,47 +39,40 @@ const ChannelNavBar = ({currentUserId, channel, onPress}: ChannelNavBar) => {
height = layoutHeight;
}
DeviceEventEmitter.emit(VIEWS.CHANNEL_NAV_BAR_CHANGED, layoutHeight);
DeviceEventEmitter.emit(ViewConstants.CHANNEL_NAV_BAR_CHANGED, layoutHeight);
};
switch (Platform.OS) {
case 'android':
height = VIEWS.ANDROID_TOP_PORTRAIT;
if (DEVICE.IS_TABLET) {
height = VIEWS.ANDROID_TOP_LANDSCAPE;
height = ViewConstants.ANDROID_TOP_PORTRAIT;
if (Device.IS_TABLET) {
height = ViewConstants.ANDROID_TOP_LANDSCAPE;
}
break;
case 'ios':
height = VIEWS.IOS_TOP_PORTRAIT - VIEWS.STATUS_BAR_HEIGHT;
if (DEVICE.IS_TABLET && isLandscape) {
height = ViewConstants.IOS_TOP_PORTRAIT - ViewConstants.STATUS_BAR_HEIGHT;
if (Device.IS_TABLET && isLandscape) {
height -= 1;
} else if (isLandscape) {
height = VIEWS.IOS_TOP_LANDSCAPE;
height = ViewConstants.IOS_TOP_LANDSCAPE;
canHaveSubtitle = false;
}
if (DEVICE.IS_IPHONE_WITH_INSETS && isLandscape) {
if (Device.IS_IPHONE_WITH_INSETS && isLandscape) {
canHaveSubtitle = false;
}
break;
}
let teammateId: string | undefined;
if (channel?.type === General.DM_CHANNEL) {
teammateId = getUserIdFromChannelName(currentUserId, channel.name);
}
return (
<View
onLayout={onLayout}
style={[style.header, {height: height + insets.top, paddingTop: insets.top, paddingLeft: insets.left, paddingRight: insets.right}]}
>
<ChannelTitle
currentUserId={currentUserId}
channel={channel}
onPress={onPress}
canHaveSubtitle={canHaveSubtitle}
teammateId={teammateId}
/>
</View>
);
@@ -105,4 +97,8 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
};
});
export default ChannelNavBar;
const withChannel = withObservables(['channelId'], ({channelId, database}: {channelId: string } & WithDatabaseArgs) => ({
channel: database.get(MM_TABLES.SERVER.CHANNEL).findAndObserve(channelId),
}));
export default withDatabase(withChannel(ChannelNavBar));

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {retryInitialChannel} from '@actions/remote/retry';
import FailedAction from '@components/failed_action';
import Loading from '@components/loading';
import {MM_TABLES} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import type {WithDatabaseArgs} from '@typings/database/database';
import type TeamModel from '@typings/database/models/servers/team';
type FailedChannelsProps = {
team: TeamModel;
}
const FailedChannels = ({team}: FailedChannelsProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const [loading, setLoading] = useState(false);
const title = intl.formatMessage({id: 'failed_action.something_wrong', defaultMessage: 'Something went wrong'});
const message = intl.formatMessage({id: 'failed_action.fetch_channels', defaultMessage: 'Channels could not be loaded for {teamName}.'}, {teamName: team.displayName});
const onAction = useCallback(async () => {
setLoading(true);
const {error} = await retryInitialChannel(serverUrl, team.id);
if (error) {
setLoading(false);
}
}, []);
if (loading) {
return <Loading/>;
}
return (
<FailedAction
message={message}
title={title}
onAction={onAction}
/>
);
};
const withTeam = withObservables(['teamId'], ({teamId, database}: {teamId: string} & WithDatabaseArgs) => ({
team: database.get(MM_TABLES.SERVER.TEAM).findAndObserve(teamId),
}));
export default withDatabase(withTeam(FailedChannels));

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {retryInitialTeamAndChannel} from '@actions/remote/retry';
import FailedAction from '@components/failed_action';
import Loading from '@components/loading';
import {useServerUrl} from '@context/server_url';
const FailedTeams = () => {
const intl = useIntl();
const serverUrl = useServerUrl();
const [loading, setLoading] = useState(false);
const title = intl.formatMessage({id: 'failed_action.something_wrong', defaultMessage: 'Something went wrong'});
const message = intl.formatMessage({id: 'failed_action.fetch_teams', defaultMessage: 'An error ocurred while loading the teams of this server'});
const onAction = useCallback(async () => {
setLoading(true);
const {error} = await retryInitialTeamAndChannel(serverUrl);
if (error) {
setLoading(false);
}
}, []);
if (loading) {
return <Loading/>;
}
return (
<FailedAction
message={message}
title={title}
onAction={onAction}
/>
);
};
export default FailedTeams;

View File

@@ -1,49 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import React, {useMemo} from 'react';
import {Text, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {logout} from '@actions/remote/general';
import {logout} from '@actions/remote/session';
import ServerVersion from '@components/server_version';
import StatusBar from '@components/status_bar';
import ViewTypes from '@constants/view';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server_url';
import {isMinimumServerVersion} from '@utils/helpers';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {unsupportedServer} from '@utils/supported_server/supported_server';
import {isSystemAdmin as isUserSystemAdmin} from '@utils/user';
import {Colors} from 'react-native/Libraries/NewAppScreen';
import ChannelNavBar from './channel_nav_bar';
import type ChannelModel from '@typings/database/models/servers/channel';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
import type {LaunchType} from '@typings/launch';
import {useTheme} from '@context/theme';
import {Text, View} from 'react-native';
import type {LaunchProps} from '@typings/launch';
import type {WithDatabaseArgs} from '@typings/database/database';
const {SERVER: {CHANNEL, SYSTEM, USER}} = MM_TABLES;
import FailedChannels from './failed_channels';
import FailedTeams from './failed_teams';
type WithDatabaseArgs = { database: Database }
type WithChannelAndThemeArgs = WithDatabaseArgs & {
type ChannelProps = WithDatabaseArgs & LaunchProps & {
currentChannelId: SystemModel;
currentUserId: SystemModel;
}
type ChannelProps = WithDatabaseArgs & {
channel: ChannelModel;
config: SystemModel;
launchType: LaunchType;
user: UserModel;
currentUserId: SystemModel;
currentTeamId: SystemModel;
time?: number;
};
const Channel = ({channel, user, config, currentUserId}: ChannelProps) => {
const {SERVER: {SYSTEM}} = MM_TABLES;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
flex: {
flex: 1,
},
sectionContainer: {
marginTop: 32,
paddingHorizontal: 24,
},
sectionTitle: {
fontSize: 24,
fontWeight: '600',
color: theme.centerChannelColor,
},
}));
const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => {
// TODO: If we have LaunchProps, ensure we load the correct channel/post/modal.
// TODO: If LaunchProps.error is true, use the LaunchProps.launchType to determine which
// error message to display. For example:
@@ -58,51 +62,47 @@ const Channel = ({channel, user, config, currentUserId}: ChannelProps) => {
//todo: https://mattermost.atlassian.net/browse/MM-37266
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
useEffect(() => {
const serverVersion = (config.value?.Version) || '';
const isSystemAdmin = isUserSystemAdmin(user.roles);
if (serverVersion) {
const {RequiredServer: {MAJOR_VERSION, MIN_VERSION, PATCH_VERSION}} = ViewTypes;
const isSupportedServer = isMinimumServerVersion(serverVersion, MAJOR_VERSION, MIN_VERSION, PATCH_VERSION);
if (!isSupportedServer) {
// Only display the Alert if the TOS does not need to show first
unsupportedServer(isSystemAdmin, intl.formatMessage);
}
}
}, [config.value?.Version, intl.formatMessage, user.roles]);
const serverUrl = useServerUrl();
const doLogout = () => {
logout(serverUrl!);
};
const renderComponent = useMemo(() => {
if (!currentTeamId.value) {
return <FailedTeams/>;
}
if (!currentChannelId.value) {
return <FailedChannels teamId={currentTeamId.value}/>;
}
return (
<ChannelNavBar
channelId={currentChannelId.value}
onPress={() => null}
/>
);
}, [currentTeamId.value, currentChannelId.value]);
return (
<SafeAreaView
style={styles.flex}
mode='margin'
edges={['left', 'right', 'bottom']}
>
<ServerVersion/>
<StatusBar theme={theme}/>
<ChannelNavBar
currentUserId={currentUserId.value}
channel={channel}
onPress={() => null}
config={config.value}
/>
{renderComponent}
<View style={styles.sectionContainer}>
<Text
onPress={doLogout}
style={styles.sectionTitle}
>
{`Logout from ${serverUrl}`}
{`Loaded in: ${time || 0}ms. Logout from ${serverUrl}`}
</Text>
</View>
@@ -110,30 +110,9 @@ const Channel = ({channel, user, config, currentUserId}: ChannelProps) => {
);
};
const getStyleSheet = makeStyleSheetFromTheme(() => ({
flex: {
flex: 1,
},
sectionContainer: {
marginTop: 32,
paddingHorizontal: 24,
},
sectionTitle: {
fontSize: 24,
fontWeight: '600',
color: Colors.black,
},
}));
export const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({
currentChannelId: database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID),
currentUserId: database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID),
config: database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG),
currentTeamId: database.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID),
}));
const withChannelAndUser = withObservables(['currentChannelId'], ({currentChannelId, currentUserId, database}: WithChannelAndThemeArgs) => ({
channel: database.collections.get(CHANNEL).findAndObserve(currentChannelId.value),
user: database.collections.get(USER).findAndObserve(currentUserId.value),
}));
export default withDatabase(withSystemIds(withChannelAndUser(Channel)));
export default withDatabase(withSystemIds(Channel));

View File

@@ -5,7 +5,7 @@ import {act, waitFor} from '@testing-library/react-native';
import React from 'react';
import {Preferences} from '@constants';
import * as UserAPICalls from '@actions/remote/user';
import * as SessionAPICalls from '@actions/remote/session';
import {renderWithIntl, fireEvent} from '@test/intl-test-helper';
import ForgotPassword from './index';
@@ -37,7 +37,7 @@ describe('ForgotPassword', () => {
});
test('Should show password link sent texts', async () => {
const spyOnResetAPICall = jest.spyOn(UserAPICalls, 'sendPasswordResetEmail');
const spyOnResetAPICall = jest.spyOn(SessionAPICalls, 'sendPasswordResetEmail');
const {getByTestId} = renderWithIntl(<ForgotPassword {...baseProps}/>);
const emailTextInput = getByTestId('forgot.password.email');
const resetButton = getByTestId('forgot.password.button');

View File

@@ -9,7 +9,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import ErrorText from '@components/error_text';
import FormattedText from '@components/formatted_text';
import {sendPasswordResetEmail} from '@actions/remote/user';
import {sendPasswordResetEmail} from '@actions/remote/session';
import {isEmail} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';

View File

@@ -21,12 +21,11 @@ import Button from 'react-native-button';
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import {NavigationFunctionComponent} from 'react-native-navigation';
import ErrorText, {ClientErrorWithIntl} from '@components/error_text';
import ErrorText from '@components/error_text';
import {FORGOT_PASSWORD, MFA} from '@constants/screens';
import FormattedText from '@components/formatted_text';
import {useManagedConfig} from '@mattermost/react-native-emm';
import {scheduleExpiredNotification} from '@actions/remote/push_notification';
import {login} from '@actions/remote/user';
import {login} from '@actions/remote/session';
import {goToScreen, resetToChannel} from '@screens/navigation';
import {t} from '@utils/i18n';
import {preventDoubleTap} from '@utils/tap';
@@ -53,7 +52,7 @@ const Login: NavigationFunctionComponent = ({config, extra, launchError, launchT
const intl = useIntl();
const managedConfig = useManagedConfig();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<ClientErrorWithIntl | string | undefined | null>();
const [error, setError] = useState<Partial<ClientErrorProps> | string | undefined | null>();
const [loginId, setLoginId] = useState<string>('');
const [password, setPassword] = useState<string>('');
@@ -130,25 +129,35 @@ const Login: NavigationFunctionComponent = ({config, extra, launchError, launchT
});
const signIn = async () => {
const result = await login(serverUrl!, {loginId: loginId.toLowerCase(), password, config, license});
const result: LoginActionResponse = await login(serverUrl!, {loginId: loginId.toLowerCase(), password, config, license});
if (checkLoginResponse(result)) {
await goToChannel();
if (!result.hasTeams && !result.error) {
// eslint-disable-next-line no-console
console.log('GO TO NO TEAMS');
return;
}
await goToChannel(result.time || 0, result.error as never);
}
};
const goToChannel = async () => {
await scheduleExpiredNotification(serverUrl!, intl);
resetToChannel({extra, launchError, launchType, serverUrl});
const goToChannel = async (time: number, loginError?: never) => {
const hasError = launchError || Boolean(loginError);
resetToChannel({extra, launchError: hasError, launchType, serverUrl, time});
};
const checkLoginResponse = (data: any) => {
if (MFA_EXPECTED_ERRORS.includes(data?.error?.server_error_id)) {
const checkLoginResponse = (data: LoginActionResponse) => {
let errorId = '';
if (typeof data.error === 'object' && data.error.server_error_id) {
errorId = data.error.server_error_id;
}
if (data.failed && MFA_EXPECTED_ERRORS.includes(errorId)) {
goToMfa();
setIsLoading(false);
return false;
}
if (data?.error) {
if (data?.error && data.failed) {
setIsLoading(false);
setError(getLoginErrorMessage(data.error));
return false;
@@ -165,11 +174,15 @@ const Login: NavigationFunctionComponent = ({config, extra, launchError, launchT
goToScreen(screen, title, {goToChannel, loginId, password, config, license, serverUrl, theme});
};
const getLoginErrorMessage = (loginError: any) => {
return (getServerErrorForLogin(loginError) || loginError);
const getLoginErrorMessage = (loginError: string | ClientErrorProps) => {
if (typeof loginError === 'string') {
return loginError;
}
return getServerErrorForLogin(loginError);
};
const getServerErrorForLogin = (serverError: any) => {
const getServerErrorForLogin = (serverError?: ClientErrorProps) => {
if (!serverError) {
return null;
}

View File

@@ -9,11 +9,12 @@ import {waitFor, renderWithIntl, fireEvent} from '@test/intl-test-helper';
import Login from './index';
jest.mock('@actions/remote/user', () => {
jest.mock('@actions/remote/session', () => {
return {
login: () => {
return {
data: undefined,
failed: true,
error: {
server_error_id: 'mfa.validate_token.authenticate.app_error',
},

View File

@@ -18,14 +18,14 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import ErrorText from '@components/error_text';
import FormattedText from '@components/formatted_text';
import {login} from '@actions/remote/user';
import {login} from '@actions/remote/session';
import {t} from '@utils/i18n';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type MFAProps = {
config: Partial<ClientConfig>;
goToChannel: () => void;
goToChannel: (time: number, error?: never) => void;
license: Partial<ClientLicense>;
loginId: string;
password: string;
@@ -34,6 +34,7 @@ type MFAProps = {
}
const MFA = ({config, goToChannel, license, loginId, password, serverUrl, theme}: MFAProps) => {
const intl = useIntl();
const [token, setToken] = useState<string>('');
const [error, setError] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
@@ -74,13 +75,27 @@ const MFA = ({config, goToChannel, license, loginId, password, serverUrl, theme}
return;
}
setIsLoading(true);
const result = await login(serverUrl, {loginId, password, mfaToken: token, config, license});
const result: LoginActionResponse = await login(serverUrl, {loginId, password, mfaToken: token, config, license});
setIsLoading(false);
if (result?.error) {
setError(result?.error);
if (result?.error && result.failed) {
if (typeof result.error == 'string') {
setError(result?.error);
return;
}
if (result.error.intl) {
setError(intl.formatMessage({id: result.error.intl.id, defaultMessage: result.error.intl.defaultMessage}, result.error.intl.values));
return;
}
setError(result.error.message);
}
if (!result.hasTeams && !result.error) {
// eslint-disable-next-line no-console
console.log('GO TO NO TEAMS');
return;
}
goToChannel();
goToChannel(result.time || 0, result.error as never);
});
const getProceedView = () => {

View File

@@ -9,9 +9,9 @@ import {renderWithIntl} from '@test/intl-test-helper';
import Mfa from './index';
jest.mock('@actions/remote/user', () => {
jest.mock('@actions/remote/session', () => {
return {
login: jest.fn(),
login: jest.fn().mockResolvedValue({error: undefined, hasTeams: true}),
};
});

View File

@@ -16,7 +16,7 @@ import {doPing} from '@actions/remote/general';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import LocalConfig from '@assets/config.json';
import AppVersion from '@components/app_version';
import ErrorText, {ClientErrorWithIntl} from '@components/error_text';
import ErrorText from '@components/error_text';
import FormattedText from '@components/formatted_text';
import {Screens} from '@constants';
import NetworkManager from '@init/network_manager';
@@ -47,7 +47,7 @@ const Server: NavigationFunctionComponent = ({componentId, extra, launchType, la
id: 'mobile.launchError.notification',
defaultMessage: 'Did not find a server for this notification',
}) : undefined;
const [error, setError] = useState<ClientErrorWithIntl|string|undefined>(initialError);
const [error, setError] = useState<Partial<ClientErrorProps>|string|undefined>(initialError);
const [url, setUrl] = useState<string>('');
const styles = getStyleSheet(theme);

View File

@@ -3,13 +3,10 @@
import {useManagedConfig} from '@mattermost/react-native-emm';
import React, {useState} from 'react';
import {useIntl} from 'react-intl';
import {SSO as SSOEnum} from '@constants';
import {scheduleExpiredNotification} from '@actions/remote/push_notification';
import {ssoLogin} from '@actions/remote/user';
import {ssoLogin} from '@actions/remote/session';
import {resetToChannel} from '@screens/navigation';
import {ErrorApi} from '@typings/api/client';
import {isMinimumServerVersion} from '@utils/helpers';
import type {LaunchProps} from '@typings/launch';
@@ -25,7 +22,6 @@ interface SSOProps extends LaunchProps {
}
const SSO = ({config, extra, launchError, launchType, serverUrl, ssoType, theme}: SSOProps) => {
const intl = useIntl();
const managedConfig = useManagedConfig();
const [loginError, setLoginError] = useState<string>('');
@@ -61,8 +57,13 @@ const SSO = ({config, extra, launchError, launchType, serverUrl, ssoType, theme}
break;
}
const onLoadEndError = (e: ErrorApi) => {
const onLoadEndError = (e: ClientErrorProps | string) => {
console.warn('Failed to set store from local data', e); // eslint-disable-line no-console
if (typeof e === 'string') {
setLoginError(e);
return;
}
let errorMessage = e.message;
if (e.url) {
errorMessage += `\nURL: ${e.url}`;
@@ -71,18 +72,22 @@ const SSO = ({config, extra, launchError, launchType, serverUrl, ssoType, theme}
};
const doSSOLogin = async (bearerToken: string, csrfToken: string) => {
const {error = undefined} = await ssoLogin(serverUrl!, bearerToken, csrfToken);
if (error) {
onLoadEndError(error);
setLoginError(error);
const result: LoginActionResponse = await ssoLogin(serverUrl!, bearerToken, csrfToken);
if (result?.error && result.failed) {
onLoadEndError(result.error);
return;
}
goToChannel();
if (!result.hasTeams && !result.error) {
// eslint-disable-next-line no-console
console.log('GO TO NO TEAMS');
return;
}
goToChannel(result.time || 0, result.error as never);
};
const goToChannel = () => {
scheduleExpiredNotification(serverUrl!, intl);
resetToChannel({extra, launchError, launchType, serverUrl});
const goToChannel = (time: number, error?: never) => {
const hasError = launchError || Boolean(error);
resetToChannel({extra, launchError: hasError, launchType, serverUrl, time});
};
const isSSOWithRedirectURLAvailable = isMinimumServerVersion(config.Version!, 5, 33, 0);

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General, Permissions} from '@constants';
import {DEFAULT_LOCALE} from '@i18n';
import {hasPermission} from '@utils/role';
export function selectDefaultChannelForTeam(channels: Channel[], memberships: ChannelMembership[], teamId: string, roles?: Role[], locale = DEFAULT_LOCALE) {
let channel: Channel|undefined;
let canIJoinPublicChannelsInTeam = false;
if (roles) {
canIJoinPublicChannelsInTeam = hasPermission(roles, Permissions.JOIN_PUBLIC_CHANNELS, true);
}
const defaultChannel = channels?.find((c) => c.name === General.DEFAULT_CHANNEL);
const iAmMemberOfTheTeamDefaultChannel = Boolean(defaultChannel && memberships?.find((m) => m.channel_id === defaultChannel.id));
const myFirstTeamChannel = channels?.filter((c) => c.team_id === teamId && c.type === General.OPEN_CHANNEL && Boolean(memberships?.find((m) => c.id === m.channel_id))).
sort(sortChannelsByDisplayName.bind(null, locale))[0];
if (iAmMemberOfTheTeamDefaultChannel || canIJoinPublicChannelsInTeam) {
channel = defaultChannel;
} else {
channel = myFirstTeamChannel || defaultChannel;
}
return channel;
}
export function sortChannelsByDisplayName(locale: string, a: Channel, b: Channel): number {
// if both channels have the display_name defined
if (a.display_name && b.display_name && a.display_name !== b.display_name) {
return a.display_name.toLowerCase().localeCompare(b.display_name.toLowerCase(), locale, {numeric: true});
}
return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, {numeric: true});
}

View File

@@ -1,9 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type SortByCreatAt = (Session | Channel | Team | Post) & {
create_at: number;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function emptyFunction(e?: any) {
// eslint-disable-line no-empty-function, @typescript-eslint/no-unused-vars
export function emptyFunction(..._args: any[]) {
// do nothing
}
// Generates a RFC-4122 version 4 compliant globally unique identifier.
@@ -25,3 +29,11 @@ export const generateId = (): string => {
});
return id;
};
export const sortByNewest = (a: SortByCreatAt, b: SortByCreatAt) => {
if (a.create_at > b.create_at) {
return -1;
}
return 1;
};

View File

@@ -1,8 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export default {
initialLoad: 0,
channelSwitch: 0,
teamSwitch: 0,
};

View File

@@ -1,11 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General} from '@constants';
import {General, Preferences} from '@constants';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
export function getUserIdFromChannelName(userId: string, channelName: string): string {
export function displayUsername(user?: UserProfile, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) {
let name = useFallbackUsername ? getLocalizedMessage(locale || DEFAULT_LOCALE, t('channel_loader.someone'), 'Someone') : '';
if (user) {
if (teammateDisplayNameSetting === Preferences.DISPLAY_PREFER_NICKNAME) {
name = user.nickname || getFullName(user);
} else if (teammateDisplayNameSetting === Preferences.DISPLAY_PREFER_FULL_NAME) {
name = getFullName(user);
} else {
name = user.username;
}
if (!name || name.trim().length === 0) {
name = user.username;
}
}
return name;
}
export function displayGroupMessageName(users: UserProfile[], locale?: string, teammateDisplayNameSetting?: string, excludeUserId?: string) {
const names: string[] = [];
const sortUsernames = (a: string, b: string) => {
return a.localeCompare(b, locale || DEFAULT_LOCALE, {numeric: true});
};
users.forEach((u) => {
if (u.id !== excludeUserId) {
names.push(displayUsername(u, locale, teammateDisplayNameSetting));
}
});
return names.sort(sortUsernames).join(', ');
}
export function getFullName(user: UserProfile): string {
if (user.first_name && user.last_name) {
return `${user.first_name} ${user.last_name}`;
} else if (user.first_name) {
return user.first_name;
} else if (user.last_name) {
return user.last_name;
}
return '';
}
export function getUserIdFromChannelName(knownUserId: string, channelName: string): string {
const ids = channelName.split('__');
if (ids[0] === userId) {
if (ids[0] === knownUserId) {
return ids[1];
}
return ids[0];

View File

@@ -1,4 +1,13 @@
{
"channel_header.directchannel.you": "{displayname} (you)",
"channel_loader.someone": "Someone",
"channel.channelHasGuests": "This channel has guests",
"channel.hasGuests": "This group message has guests",
"channel.isGuest": "This person is a guest",
"failed_action.fetch_channels": "Channels could not be loaded for {teamName}.",
"failed_action.fetch_teams": "An error ocurred while loading the teams of this server",
"failed_action.something_wrong": "Something went wrong",
"failed_action.try_again": "Try again",
"login_mfa.enterToken": "To complete the sign in process, please enter a token from your smartphone's authenticator",
"login_mfa.token": "MFA Token",
"login_mfa.tokenReq": "Please enter an MFA token",
@@ -30,6 +39,8 @@
"mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n",
"mobile.error_handler.title": "Unexpected error occurred",
"mobile.launchError.notification": "Did not find a server for this notification",
"mobile.link.error.text": "Unable to open the link.",
"mobile.link.error.title": "Error",
"mobile.login_options.choose_title": "Choose your login method",
"mobile.managed.blocked_by": "Blocked by {vendor}",
"mobile.managed.exit": "Exit",
@@ -55,13 +66,19 @@
"mobile.routes.loginOptions": "Login Chooser",
"mobile.routes.mfa": "Multi-factor Authentication",
"mobile.routes.sso": "Single Sign-On",
"mobile.server_upgrade.alert_description": "This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.",
"mobile.server_upgrade.button": "OK",
"mobile.server_upgrade.description": "\nA server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.\n",
"mobile.server_upgrade.dismiss": "Dismiss",
"mobile.server_upgrade.learn_more": "Learn More",
"mobile.server_upgrade.title": "Server upgrade required",
"mobile.server_url.deeplink.emm.denied": "This app is controlled by an EMM and the DeepLink server url does not match the EMM allowed server",
"mobile.server_url.empty": "Please enter a valid server URL",
"mobile.server_url.invalid_format": "URL must start with http:// or https://",
"mobile.session_expired": "Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.",
"mobile.unsupported_server.message": "Attachments, link previews, reactions and embed data may not be displayed correctly. If this issue persists contact your System Administrator to upgrade your Mattermost server.",
"mobile.unsupported_server.ok": "OK",
"mobile.unsupported_server.title": "Unsupported server version",
"password_form.title": "Password Reset",
"password_send.checkInbox": "Please check your inbox.",
"password_send.description": "To reset your password, enter the email address you used to sign up",

View File

@@ -706,7 +706,7 @@ SPEC CHECKSUMS:
EXFileSystem: 0a04aba8da751b9ac954065911bcf166503f8267
ExpoModulesCore: 2734852616127a6c1fc23012197890a6f3763dc7
FBLazyVector: e686045572151edef46010a6f819ade377dfeb4b
FBReactNativeSpec: d35931295aacfe996e833c01a3701d4aa7a80cb4
FBReactNativeSpec: cef0cc6d50abc92e8cf52f140aa22b5371cfec0b
glog: 73c2498ac6884b13ede40eda8228cb1eee9d9d62
jail-monkey: feb2bdedc4d67312cd41a455c22661d804bba985
libwebp: e90b9c01d99205d03b6bb8f2c8c415e5a4ef66f0

16088
package-lock.json generated

File diff suppressed because it is too large Load Diff

39
types/api/client.d.ts vendored
View File

@@ -1,30 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type logLevel = 'ERROR' | 'WARNING' | 'INFO';
export type GenericClientResponse = {
response: any;
headers: Map<string, string>;
data: any;
};
export type ErrorOffline = {
message: string;
url: string;
};
export type ErrorInvalidResponse = {
intl: {
id: string;
defaultMessage: string;
};
};
export type ErrorApi = {
message: string;
server_error_id: string;
status_code: number;
url: string;
};
export type Client4Error = ErrorOffline | ErrorInvalidResponse | ErrorApi;
export type ClientOptions = {
type logLevel = 'ERROR' | 'WARNING' | 'INFO';
type ClientOptions = {
body?: any;
method?: string;
};
interface ClientErrorProps extends Error {
details: Error;
intl?:
{defaultMessage?: string; id: string; values?: Record<string, any>} |
{ defaultMessage?: string; id: string; values?: Record<string, any> } |
{ id: string; defaultMessage?: string; values?: Record<string, any> } |
{ id: string; defaultMessage?: string; values?: Record<string, any> };
url: string;
server_error_id?: string;
status_code?: number;
}

View File

@@ -1,10 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
interface ClientErrorProps extends Error {
details: Error;
intl?: {defaultMessage?: string; id: string} | { defaultMessage?: string; id: string } | { id: string; defaultMessage?: string; values: any } | { id: string; defaultMessage?: string };
url: string;
server_error_id?: string | number;
status_code?: number;
}

View File

@@ -3,8 +3,15 @@
interface Session {
id: string;
create_at: string|number;
create_at: number;
device_id?: string;
expires_at: string|number;
expires_at: number;
user_id: string;
}
interface LoginActionResponse {
error?: ClientErrorProps | string;
hasTeams?: boolean;
failed: boolean;
time?: number;
}

View File

@@ -16,6 +16,8 @@ import type System from '@typings/database/models/servers/system';
import {DatabaseType} from './enums';
export type WithDatabaseArgs = { database: Database }
export type CreateServerDatabaseConfig = {
dbName: string;
dbType?: DatabaseType.DEFAULT | DatabaseType.SERVER;

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Query, Relation} from '@nozbe/watermelondb';
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
/**
@@ -24,7 +24,7 @@ export default class ChannelModel extends Model {
deleteAt: number;
/** update_at : The timestamp to when this channel was last updated on the server */
updateAt!: number;
updateAt!: number;
/** display_name : The channel display name (e.g. Town Square ) */
displayName: string;
@@ -63,11 +63,11 @@ export default class ChannelModel extends Model {
creator: Relation<UserModel>;
/** info : Query returning extra information about this channel from the CHANNEL_INFO table */
info: Query<ChannelInfoModel>;
info: Relation<ChannelInfoModel>;
/** membership : Query returning the membership data for the current user if it belongs to this channel */
membership: Query<MyChannelModel>;
membership: Relation<MyChannelModel>;
/** settings: User specific settings/preferences for this channel */
settings: Query<MyChannelSettingsModel>;
settings: Relation<MyChannelSettingsModel>;
}

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
/**
* ChannelInfo is an extension of the information contained in the Channel table.
@@ -13,9 +13,6 @@ export default class ChannelInfoModel extends Model {
/** table (name) : ChannelInfo */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** guest_count : The number of guest in this channel */
guestCount: number;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
/**
* MyChannel is an extension of the Channel model but it lists only the Channels the app's user belongs to
@@ -11,9 +11,6 @@ export default class MyChannelModel extends Model {
/** table (name) : MyChannel */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** last_post_at : The timestamp for any last post on this channel */
lastPostAt: number;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
/**
* The MyChannelSettings model represents the specific user's configuration to
@@ -12,9 +12,6 @@ export default class MyChannelSettingsModel extends Model {
/** table (name) : MyChannelSettings */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** notify_props : Configurations with regards to this channel */
notifyProps: Partial<ChannelNotifyProps>;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
/**
* MyTeam represents only the teams that the current user belongs to
@@ -11,9 +11,6 @@ export default class MyTeamModel extends Model {
/** table (name) : MyTeam */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** is_unread : Boolean flag for unread messages on team level */
isUnread: boolean;

View File

@@ -66,7 +66,7 @@ export default class PostModel extends Model {
postsInThread: PostInThreadModel[];
/** metadata: All the extra data associated with this Post */
metadata: PostMetadataModel[];
metadata: Relation<PostMetadataModel>;
/** reactions: All the reactions associated with this Post */
reactions: ReactionModel[];

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
/**
* PostMetadata provides additional information on a POST
@@ -11,9 +11,6 @@ export default class PostMetadataModel extends Model {
/** table (name) : PostMetadata */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** data : Different types of data ranging from arrays, emojis, files to images and reactions. */
data: PostMetadata;

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Query} from '@nozbe/watermelondb';
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
/**
@@ -48,13 +48,13 @@ export default class TeamModel extends Model {
groupsInTeam: GroupsInTeamModel[];
/** myTeam : Retrieves additional information about the team that this user is possibly part of. This query might yield no result if the user isn't part of a team. */
myTeam: Query<MyTeamModel>;
myTeam: Relation<MyTeamModel>;
/** slashCommands : All the slash commands associated with this team */
slashCommands: SlashCommandModel[];
/** teamChannelHistory : A history of the channels in this team that has been visited, ordered by the most recent and capped to the last 5 */
teamChannelHistory: Query<TeamChannelHistoryModel>;
teamChannelHistory: Relation<TeamChannelHistoryModel>;
/** members : All the users associated with this team */
members: TeamMembershipModel[];

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import Model from '@nozbe/watermelondb/Model';
/**
* The TeamChannelHistory model helps keeping track of the last channel visited
@@ -12,9 +12,6 @@ export default class TeamChannelHistoryModel extends Model {
/** table (name) : TeamChannelHistory */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** channel_ids : An array containing the last 5 channels visited within this team order by recency */
channelIds: string[];