MM-35115 [Gekidou] Login flow - Email and Password (#5402)

* MM_35115: ADDED select_server screen

* MM_35115: ADDED select_server screen

* MM_35115: ADDED files on which select_server is dependent

* MM_35115: ADDED react-native-button

* MM_35115: Fixing TS issues [IN PROGRESS]

* MM_35115: Started withObservables [IN PROGRESS]

* MM_35115: Started withObservables [IN PROGRESS]

* MM_35115: withObservables - defaulting when no connection is available [IN PROGRESS]

* MM_35115: withObservables - some code clean up [IN PROGRESS]

* MM_35115: withObservables - some code clean up [IN PROGRESS]

* MM_35115: withObservables - some code clean up [IN PROGRESS]

* MM_35115: Substituting mapDispatchToProps [IN PROGRESS]

* MM_35115: Substituting mapDispatchToProps [IN PROGRESS]

* MM_35115: Substituting mapDispatchToProps [IN PROGRESS]

* MM_35115: Removed resetPing action [IN PROGRESS]

* MM_35115: ADDED app/client

* MM_35115: Preparing scheduleExpiredNotification

* MM_35115: Adding some todos

* Server & LoginOptions

* Use default server if available and autoconnect if configured

* Fix login header & manual server url

* MM_35115: Login Options[IN PROGRESS]

* MM_35115: Login screen - email [IN PROGRESS]

* MM_35115: Login screen - email [IN PROGRESS]

* MM_35115: Login screen - email - login api call [IN PROGRESS]

* MM_35115: Login screen - email - login api call [IN PROGRESS]

* MM_35115: Login screen - email - saving to server db [IN PROGRESS]

* MM_35115: Login screen - email - saving to System, Preferences to db [IN PROGRESS]

* MM_35115: Login screen - enforcing unique check on System entity [IN PROGRESS]

* MM_35115: Login screen - writing TeamMembership [IN PROGRESS]

* MM_35115: Login screen - writing Teams [IN PROGRESS]

* MM_35115: Login screen [IN PROGRESS]

* MM_35115: Login screen- Refactored DataOperator handlers [IN PROGRESS]

* MM_35115: Login screen - Proper clean up [IN PROGRESS]

* MM_35115: Login screen - completeLogin  [IN PROGRESS]

* MM_35115: Improving DataOperator

* MM_35115: Improving DataOperator

* MM_35115: 80% DONE - login with email and password - some todos

* MM_35115: 80% DONE - login with email and password - some todos

* MM_35115: 80% DONE - login with email and password - some todos

* MM_35115: Removing unused app/queries folder

* MM_35115: Clean up

* MM_35115: Clean up

* MM_35115: Clean up

* MM_35115: Clean up

* MM_35115: Clean up

* MM_35115: Adding roles for MYTEAM

* MM_35115: Code clean up

* MM_35115: Code clean up

* MM_35115: Code clean up

* MM_35115: Adding rn-fetch-blob for Android

* MM_35115: Code clean up

* MM_35115: Code clean up

* MM_35115: Added test setup

* MM_35115: Fix database utils

* MM_35115: ADDED loadRolesIfNeeded

* MM_35115: Fix TS issue

* MM_35115: ADDED Tests setup

* MM_35115: Fix TS issues

* MM_35115: Fix TS issues

* MM_35115: Fix TS issues

* MM_35115: Added alternative to site name

* MM_35115: Added alternative to site name

* MM_35115: Removed hardcoded values

* MM_35115: Clean up

* MM_35115 - Fixed Android platform check instead of hermes

* MM_35115  - Replaced emptyErrorHandlingFunction with emptyFunction

* MM_35115 : Implemented TS fixes

* Update index.ts

* MM-35115 - Fix react-test-renderer issue

* MM_35115 - Optimizing DatabaseManager

* MM_35115 : Implemented getDatabaseConnection

* MM_35115 : Refactoring set/getActiveDatabase to use flag record

* MM_35115 : Refactored active database to use flag in Global entity

* MM_35115 : Updated manual database manager test

* MM_35115 : Fix operator/utils/test

* MM_35115 : Fix for base_handler

* MM_35115 : Fix test issues with Handlers

* MM_35115 : Fix test issues with prepareRecords

* MM_35115 : Fix wrapper test issue

* MM_35115 : Updated getMostRecentServerConnection to return the serverUrl as well as the connection

* MM_35115 : Refactored the way we call DataOperator

* MM_35115 : Updated database manager mock

* Add getMostRecentServerUrl function (#5440)

* fix: add getMostRecentServerUrl func

* fix: add ts and tsx to editorconfig

* fix: rename functions

* fix: return type

* Fix unit test setup

* fix login screen unit tests

* MM-36205 [GEKIDOU] Login Flow SSO (#5454)

* MM_35115: Starting LoginOptions SSO

* MM_36205: SSO [IN PROGRESS]

* MM_36205 : SSO [ IN PROGRESS ]

* Update sso_with_redirect_url.tsx

* MM_36205 : SSO Tests [ IN PROGRESS ]

* MM_36205 : Passing serverUrl to SSO screen

* Update sso.test.tsx

* Fix ViewTypes imports and keyMirror method

* MM_36205 : Code clean up

* Fix : Clean up imports

* Update: Aligning with PR 5452

* Fix: AndroidManifest file to include redirection ofr scheme mmauthbeta

* refactor: SSO Login method via Gitlab now navigates to Channel screen

* refactor: SSO Login without redirectURL is also working

* feat: SSO - main test completed

* feat: ADDED test for sso_with_redirect_url

* fix : eslint correction

* fix: Updated Loading component name

* fix : code clean up from reviews

* fix: reviews check

* fix: Added mmauthbeta into info.plist

* Revert "fix: Added mmauthbeta into info.plist"

This reverts commit d87cc23f5b451a49d9ffa9de4745224b7ecfc977.

* Update Info.plist

* Update AppDelegate.m

* feat: ADDED Forgot Password - Test [ IN PROGRESS ]

* feat: Forgot Password - Completed & Tested

* fix: Including MFA screen [ IN PROGRESS ]

* MFA - Properly tested

* Properly testing forgot_password screen

* Fix login.test.tsx

* Fix SSO method calls chain

* Update index.tsx

* Sort imports for sceen/navigation

* fix: Reviews

* Update signing + act in test

* Removed todo comment on MFA

* feedback review

* fix login tests

Co-authored-by: Avinash Lingaloo <>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* App initialization refactor (#5430)

* fix: initial init refactor

* fix: await isServerPresent

* fix: more refactor

* fix: move out launch functions

* fix: remove comment

* fix: update credential functions

* fix: refactor launch functions

* fix: deep link parsing

* fix: lint change

* fix: update deeplink and notification handlers

* fix: indentation

* fix: add relaunchApp

* fix eslint

* refactor launchProps and autoconnect server for deeplink

* fix: use undefined

* fix: define OptionalLaunchProps

* fix: Android - handle server URL in push notification

* fix: rename func

* fix: use boolean launchError instead

* fix: use DatabaseModule

* fix: use DatabaseHelper instead

* fix: remove unnecessary null check

* fix: iOS - support for serverUrl

* fix: iOS - extract serverUrl in reply action

* fix: iOS - expose objc specific func

* fix: remove unnecessary deviceToken param

* fix: return if device is untrusted

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* fix: bye bye modulePaths

* fix: ios build

* chore: remove unused aliases from babel.config

* chore: fix dependency format in package-lock.json

* chore: remove transparent window background color for android AppTheme

* chore: remove mattermost.js and use index.ts as app entry

* fix: login flow screens theme

* fix: Launch types

* chore: remove OptionalLaunchProps type

* fix: url utils unit tests

* chore: update en.json

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

Co-authored-by: Avinash Lingaloo <>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
This commit is contained in:
Avinash Lingaloo 2021-06-18 08:57:40 +04:00 committed by GitHub
parent 8742a2b1df
commit 3ee6e673c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
207 changed files with 22050 additions and 2252 deletions

View file

@ -7,7 +7,7 @@ end_of_line = lf
insert_final_newline = true
charset = utf-8
[*.{js,jsx,json,html}]
[*.{js,jsx,json,html,ts,tsx}]
indent_style = space
indent_size = 4

View file

@ -2,7 +2,8 @@
"extends": [
"plugin:mattermost/react",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": [

1
.gitignore vendored
View file

@ -101,3 +101,4 @@ detox/artifacts
#editor-settings
.vscode
.scannerwork

View file

@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.mattermost.rnbeta">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.mattermost.rnbeta">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
@ -44,17 +43,16 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mattermost" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mmauthbeta" />
</intent-filter>
</activity>
<service android:name=".NotificationDismissService"
android:enabled="true"
android:exported="false" />
<receiver android:name=".NotificationReplyBroadcastReceiver"
android:enabled="true"
android:exported="false" />
<activity
android:name="com.reactnativenavigation.controllers.NavigationActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:resizeableActivity="true"/>
<service android:name=".NotificationDismissService" android:enabled="true" android:exported="false" />
<receiver android:name=".NotificationReplyBroadcastReceiver" android:enabled="true" android:exported="false" />
<activity android:name="com.reactnativenavigation.controllers.NavigationActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" android:resizeableActivity="true"/>
<activity
android:name="com.mattermost.share.ShareActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"

View file

@ -1,8 +1,5 @@
package com.mattermost.helpers;
import java.util.ArrayList;
import java.util.HashMap;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.WritableMap;
@ -10,27 +7,9 @@ import com.oblador.keychain.KeychainModule;
public class Credentials {
static final String CURRENT_SERVER_URL = "@currentServerUrl";
public static void getCredentialsForCurrentServer(ReactApplicationContext context, ResolvePromise promise) {
public static void getCredentialsForServer(ReactApplicationContext context, String serverUrl, ResolvePromise promise) {
final KeychainModule keychainModule = new KeychainModule(context);
final AsyncStorage asyncStorage = new AsyncStorage(context);
final ArrayList<String> keys = new ArrayList<String>(1);
keys.add(CURRENT_SERVER_URL);
KeysReadableArray asyncStorageKeys = new KeysReadableArray() {
@Override
public int size() {
return keys.size();
}
@Override
public String getString(int index) {
return keys.get(index);
}
};
HashMap<String, String> asyncStorageResults = asyncStorage.multiGet(asyncStorageKeys);
String serverUrl = asyncStorageResults.get(CURRENT_SERVER_URL);
final WritableMap options = Arguments.createMap();
// KeyChain module fails if `authenticationPrompt` is not set
final WritableMap authPrompt = Arguments.createMap();

View file

@ -0,0 +1,34 @@
package com.mattermost.helpers;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import com.nozbe.watermelondb.Database;
public class DatabaseHelper {
private static final String DEFAULT_DATABASE_NAME = "default.db";
private static Database defaultDatabase;
private static void setDefaultDatabase(Context context) {
String databaseName = Uri.fromFile(context.getFilesDir()).toString() + "/" + DEFAULT_DATABASE_NAME;
defaultDatabase = new Database(databaseName, context);
}
public static String getOnlyServerUrl(Context context) {
if (defaultDatabase == null) {
setDefaultDatabase(context);
}
String emptyArray[] = {};
String query = "SELECT url FROM servers";
Cursor cursor = defaultDatabase.rawQuery(query, emptyArray);
if (cursor.getCount() == 1) {
cursor.moveToFirst();
return cursor.getString(0);
}
return null;
}
}

View file

@ -27,6 +27,8 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.mattermost.helpers.DatabaseHelper;
import com.mattermost.helpers.ResolvePromise;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
@ -35,11 +37,10 @@ import com.wix.reactnativenotifications.core.JsIOHelper;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
import com.mattermost.helpers.ResolvePromise;
public class CustomPushNotification extends PushNotification {
public static final int MESSAGE_NOTIFICATION_ID = 435345;
public static final String GROUP_KEY_MESSAGES = "mm_group_key_messages";
public static final String NOTIFICATION = "notification";
public static final String NOTIFICATION_ID = "notificationId";
public static final String KEY_TEXT_REPLY = "CAN_REPLY";
public static final String NOTIFICATION_REPLIED_EVENT_NAME = "notificationReplied";
@ -61,6 +62,7 @@ public class CustomPushNotification extends PushNotification {
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
this.context = context;
createNotificationChannels();
}
@ -106,8 +108,10 @@ public class CustomPushNotification extends PushNotification {
final boolean isIdLoaded = initialData.getString("id_loaded") != null ? initialData.getString("id_loaded").equals("true") : false;
int notificationId = MESSAGE_NOTIFICATION_ID;
if (ackId != null) {
notificationReceiptDelivery(ackId, postId, type, isIdLoaded, new ResolvePromise() {
String serverUrl = initialData.getString("server_url", DatabaseHelper.getOnlyServerUrl(context));
if (ackId != null && serverUrl != null) {
notificationReceiptDelivery(ackId, serverUrl, postId, type, isIdLoaded, new ResolvePromise() {
@Override
public void resolve(@Nullable Object value) {
if (isIdLoaded) {
@ -127,6 +131,11 @@ public class CustomPushNotification extends PushNotification {
// so we fetch the bundle again
final Bundle data = mNotificationProps.asBundle();
if (serverUrl == null) {
String message = data.getString("message");
data.putString("message", "Unknown Server\n" + message);
}
if (channelId != null) {
notificationId = channelId.hashCode();
@ -465,15 +474,18 @@ public class CustomPushNotification extends PushNotification {
private void addNotificationReplyAction(Notification.Builder notification, int notificationId, Bundle bundle) {
String postId = bundle.getString("post_id");
String serverUrl = bundle.getString("server_url", DatabaseHelper.getOnlyServerUrl(context));
if (android.text.TextUtils.isEmpty(postId) || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
if (android.text.TextUtils.isEmpty(postId) ||
serverUrl == null ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
Intent replyIntent = new Intent(mContext, NotificationReplyBroadcastReceiver.class);
replyIntent.setAction(KEY_TEXT_REPLY);
replyIntent.putExtra(NOTIFICATION_ID, notificationId);
replyIntent.putExtra("pushNotification", bundle);
replyIntent.putExtra(NOTIFICATION, bundle);
PendingIntent replyPendingIntent = PendingIntent.getBroadcast(
mContext,
@ -540,8 +552,8 @@ public class CustomPushNotification extends PushNotification {
return message.replaceFirst(": ", "").trim();
}
private void notificationReceiptDelivery(String ackId, String postId, String type, boolean isIdLoaded, ResolvePromise promise) {
ReceiptDelivery.send(context, ackId, postId, type, isIdLoaded, promise);
private void notificationReceiptDelivery(String ackId, String serverUrl, String postId, String type, boolean isIdLoaded, ResolvePromise promise) {
ReceiptDelivery.send(context, ackId, serverUrl, postId, type, isIdLoaded, promise);
}
private void createNotificationChannels() {

View file

@ -46,7 +46,6 @@ import com.facebook.soloader.SoLoader;
import org.unimodules.adapters.react.ModuleRegistryAdapter;
import org.unimodules.adapters.react.ReactModuleRegistryProvider;
import org.unimodules.core.interfaces.SingletonModule;
import com.swmansion.reanimated.ReanimatedJSIModulePackage;

View file

@ -48,9 +48,11 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
final int notificationId = intent.getIntExtra(CustomPushNotification.NOTIFICATION_ID, -1);
final Bundle notification = intent.getBundleExtra(CustomPushNotification.NOTIFICATION);
final String serverUrl = notification.getString("serverUrl");
Credentials.getCredentialsForCurrentServer(reactApplicationContext, new ResolvePromise() {
Credentials.getCredentialsForServer(reactApplicationContext, serverUrl, new ResolvePromise() {
@Override
public void resolve(@Nullable Object value) {
if (value instanceof Boolean && !(Boolean)value) {

View file

@ -22,14 +22,12 @@ import com.facebook.react.bridge.WritableMap;
import com.mattermost.helpers.*;
public class ReceiptDelivery {
static final String CURRENT_SERVER_URL = "@currentServerUrl";
private static final int[] FIBONACCI_BACKOFFS = new int[] { 0, 1, 2, 3, 5, 8 };
public static void send(Context context, final String ackId, final String postId, final String type, final boolean isIdLoaded, ResolvePromise promise) {
public static void send(Context context, final String ackId, final String serverUrl, final String postId, final String type, final boolean isIdLoaded, ResolvePromise promise) {
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
Credentials.getCredentialsForCurrentServer(reactApplicationContext, new ResolvePromise() {
Credentials.getCredentialsForServer(reactApplicationContext, serverUrl, new ResolvePromise() {
@Override
public void resolve(@Nullable Object value) {
if (value instanceof Boolean && !(Boolean)value) {

View file

@ -2,9 +2,6 @@
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowBackground">@android:color/transparent</item>
</style>
<style name="LightTheme" parent="Theme.AppCompat.Light.NoActionBar">

43
app/client/rest/apps.ts Normal file
View file

@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
export interface ClientAppsMix {
executeAppCall: (call: AppCallRequest, type: AppCallType) => Promise<AppCallResponse>;
getAppsBindings: (userID: string, channelID: string, teamID: string) => Promise<AppBinding[]>;
}
const ClientApps = (superclass: any) => class extends superclass {
executeAppCall = async (call: AppCallRequest, type: AppCallType) => {
const callCopy = {
...call,
path: `${call.path}/${type}`,
context: {
...call.context,
user_agent: 'mobile',
},
};
return this.doFetch(
`${this.getAppsProxyRoute()}/api/v1/call`,
{method: 'post', body: JSON.stringify(callCopy)},
);
}
getAppsBindings = async (userID: string, channelID: string, teamID: string) => {
const params = {
user_id: userID,
channel_id: channelID,
team_id: teamID,
user_agent: 'mobile',
};
return this.doFetch(
`${this.getAppsProxyRoute()}/api/v1/bindings${buildQueryString(params)}`,
{method: 'get'},
);
}
};
export default ClientApps;

375
app/client/rest/base.ts Normal file
View file

@ -0,0 +1,375 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ClientOptions} from '@typings/api/client4';
import urlParse from 'url-parse';
import {Analytics, create} from '@init/analytics';
import * as ClientConstants from './constants';
import ClientError from './error';
export default class ClientBase {
analytics: Analytics|undefined;
clusterId = '';
csrf = '';
defaultHeaders: {[x: string]: string} = {};
diagnosticId = '';
enableLogging = false;
includeCookies = true;
logToConsole = false;
managedConfig: any = null;
serverVersion = '';
token = '';
translations = {
connectionError: 'There appears to be a problem with your internet connection.',
unknownError: 'We received an unexpected status code from the server.',
};
userAgent: string|null = null;
url = '';
urlVersion = '/api/v4';
getAbsoluteUrl(baseUrl: string) {
if (typeof baseUrl !== 'string' || !baseUrl.startsWith('/')) {
return baseUrl;
}
return this.getUrl() + baseUrl;
}
getOptions(options: ClientOptions) {
const newOptions: ClientOptions = {...options};
const headers: {[x: string]: string} = {
[ClientConstants.HEADER_REQUESTED_WITH]: 'XMLHttpRequest',
...this.defaultHeaders,
};
if (this.token) {
headers[ClientConstants.HEADER_AUTH] = `${ClientConstants.HEADER_BEARER} ${this.token}`;
}
const csrfToken = this.csrf || '';
if (options.method && options.method.toLowerCase() !== 'get' && csrfToken) {
headers[ClientConstants.HEADER_X_CSRF_TOKEN] = csrfToken;
}
if (this.includeCookies) {
newOptions.credentials = 'include';
}
if (this.userAgent) {
headers[ClientConstants.HEADER_USER_AGENT] = this.userAgent;
}
if (newOptions.headers) {
Object.assign(headers, newOptions.headers);
}
return {
...newOptions,
headers,
};
}
getServerVersion() {
return this.serverVersion;
}
getToken() {
return this.token;
}
getUrl() {
return this.url;
}
getUrlVersion() {
return this.urlVersion;
}
getWebSocketUrl = () => {
return `${this.getBaseRoute()}/websocket`;
}
setAcceptLanguage(locale: string) {
this.defaultHeaders['Accept-Language'] = locale;
}
setCSRF(csrfToken: string) {
this.csrf = csrfToken;
}
setDiagnosticId(diagnosticId: string) {
this.diagnosticId = diagnosticId;
}
setEnableLogging(enable: boolean) {
this.enableLogging = enable;
}
setIncludeCookies(include: boolean) {
this.includeCookies = include;
}
setManagedConfig(config: any) {
this.managedConfig = config;
}
setUserAgent(userAgent: string) {
this.userAgent = userAgent;
}
setToken(token: string) {
this.token = token;
}
setUrl(url: string) {
this.url = url.replace(/\/+$/, '');
this.analytics = create(this.url);
}
// Routes
getBaseRoute() {
return `${this.url}${this.urlVersion}`;
}
getUsersRoute() {
return `${this.getBaseRoute()}/users`;
}
getUserRoute(userId: string) {
return `${this.getUsersRoute()}/${userId}`;
}
getTeamsRoute() {
return `${this.getBaseRoute()}/teams`;
}
getTeamRoute(teamId: string) {
return `${this.getTeamsRoute()}/${teamId}`;
}
getTeamNameRoute(teamName: string) {
return `${this.getTeamsRoute()}/name/${teamName}`;
}
getTeamMembersRoute(teamId: string) {
return `${this.getTeamRoute(teamId)}/members`;
}
getTeamMemberRoute(teamId: string, userId: string) {
return `${this.getTeamMembersRoute(teamId)}/${userId}`;
}
getChannelsRoute() {
return `${this.getBaseRoute()}/channels`;
}
getChannelRoute(channelId: string) {
return `${this.getChannelsRoute()}/${channelId}`;
}
getChannelMembersRoute(channelId: string) {
return `${this.getChannelRoute(channelId)}/members`;
}
getChannelMemberRoute(channelId: string, userId: string) {
return `${this.getChannelMembersRoute(channelId)}/${userId}`;
}
getPostsRoute() {
return `${this.getBaseRoute()}/posts`;
}
getPostRoute(postId: string) {
return `${this.getPostsRoute()}/${postId}`;
}
getReactionsRoute() {
return `${this.getBaseRoute()}/reactions`;
}
getCommandsRoute() {
return `${this.getBaseRoute()}/commands`;
}
getFilesRoute() {
return `${this.getBaseRoute()}/files`;
}
getFileRoute(fileId: string) {
return `${this.getFilesRoute()}/${fileId}`;
}
getPreferencesRoute(userId: string) {
return `${this.getUserRoute(userId)}/preferences`;
}
getIncomingHooksRoute() {
return `${this.getBaseRoute()}/hooks/incoming`;
}
getIncomingHookRoute(hookId: string) {
return `${this.getBaseRoute()}/hooks/incoming/${hookId}`;
}
getOutgoingHooksRoute() {
return `${this.getBaseRoute()}/hooks/outgoing`;
}
getOutgoingHookRoute(hookId: string) {
return `${this.getBaseRoute()}/hooks/outgoing/${hookId}`;
}
getOAuthRoute() {
return `${this.url}/oauth`;
}
getOAuthAppsRoute() {
return `${this.getBaseRoute()}/oauth/apps`;
}
getOAuthAppRoute(appId: string) {
return `${this.getOAuthAppsRoute()}/${appId}`;
}
getEmojisRoute() {
return `${this.getBaseRoute()}/emoji`;
}
getEmojiRoute(emojiId: string) {
return `${this.getEmojisRoute()}/${emojiId}`;
}
getBrandRoute() {
return `${this.getBaseRoute()}/brand`;
}
getBrandImageUrl(timestamp: string) {
return `${this.getBrandRoute()}/image?t=${timestamp}`;
}
getDataRetentionRoute() {
return `${this.getBaseRoute()}/data_retention`;
}
getRolesRoute() {
return `${this.getBaseRoute()}/roles`;
}
getTimezonesRoute() {
return `${this.getBaseRoute()}/system/timezones`;
}
getRedirectLocationRoute() {
return `${this.getBaseRoute()}/redirect_location`;
}
getBotsRoute() {
return `${this.getBaseRoute()}/bots`;
}
getBotRoute(botUserId: string) {
return `${this.getBotsRoute()}/${botUserId}`;
}
getAppsProxyRoute() {
return `${this.url}/plugins/com.mattermost.apps`;
}
// Client Helpers
handleRedirectProtocol = (url: string, response: Response) => {
const serverUrl = this.getUrl();
const parsed = urlParse(url);
if (response.redirected) {
const redirectUrl = urlParse(response.url);
if (serverUrl === parsed.origin && parsed.host === redirectUrl.host && parsed.protocol !== redirectUrl.protocol) {
this.setUrl(serverUrl.replace(parsed.protocol, redirectUrl.protocol));
}
}
};
doFetch = async (url: string, options: ClientOptions) => {
const {data} = await this.doFetchWithResponse(url, options);
return data;
};
doFetchWithResponse = async (url: string, options: ClientOptions) => {
const response = await fetch(url, this.getOptions(options));
const headers = parseAndMergeNestedHeaders(response.headers);
let data;
try {
data = await response.json();
} catch (err) {
throw new ClientError(this.getUrl(), {
message: 'Received invalid response from the server.',
intl: {
id: 'mobile.request.invalid_response',
defaultMessage: 'Received invalid response from the server.',
},
url,
});
}
if (headers.has(ClientConstants.HEADER_X_VERSION_ID) && !headers.get('Cache-Control')) {
const serverVersion = headers.get(ClientConstants.HEADER_X_VERSION_ID);
if (serverVersion && this.serverVersion !== serverVersion) {
this.serverVersion = serverVersion;
}
}
if (headers.has(ClientConstants.HEADER_X_CLUSTER_ID)) {
const clusterId = headers.get(ClientConstants.HEADER_X_CLUSTER_ID);
if (clusterId && this.clusterId !== clusterId) {
this.clusterId = clusterId;
}
}
if (response.ok) {
return {
response,
headers,
data,
};
}
const msg = data.message || '';
if (this.logToConsole) {
console.error(msg); // eslint-disable-line no-console
}
throw new ClientError(this.getUrl(), {
message: msg,
server_error_id: data.id,
status_code: data.status_code,
url,
});
};
}
function parseAndMergeNestedHeaders(originalHeaders: any) {
const headers = new Map();
let nestedHeaders = new Map();
originalHeaders.forEach((val: string, key: string) => {
const capitalizedKey = key.replace(/\b[a-z]/g, (l) => l.toUpperCase());
let realVal = val;
if (val && val.match(/\n\S+:\s\S+/)) {
const nestedHeaderStrings = val.split('\n');
realVal = nestedHeaderStrings.shift() as string;
const moreNestedHeaders = new Map(
nestedHeaderStrings.map((h: any) => h.split(/:\s/)),
);
nestedHeaders = new Map([...nestedHeaders, ...moreNestedHeaders]);
}
headers.set(capitalizedKey, realVal);
});
return new Map([...headers, ...nestedHeaders]);
}

37
app/client/rest/bots.ts Normal file
View file

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
export interface ClientBotsMix {
getBot: (botUserId: string) => Promise<Bot>;
getBots: (page?: number, perPage?: number) => Promise<Bot[]>;
getBotsIncludeDeleted: (page?: number, perPage?: number) => Promise<Bot[]>;
}
const PER_PAGE_DEFAULT = 60;
const ClientBots = (superclass: any) => class extends superclass {
getBot = async (botUserId: string) => {
return this.doFetch(
`${this.getBotRoute(botUserId)}`,
{method: 'get'},
);
}
getBots = async (page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getBotsRoute()}${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
}
getBotsIncludeDeleted = async (page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getBotsRoute()}${buildQueryString({include_deleted: true, page, per_page: perPage})}`,
{method: 'get'},
);
}
};
export default ClientBots;

309
app/client/rest/channels.ts Normal file
View file

@ -0,0 +1,309 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
export interface ClientChannelsMix {
getAllChannels: (page?: number, perPage?: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, includeTotalCount?: boolean) => Promise<any>;
createChannel: (channel: Channel) => Promise<Channel>;
createDirectChannel: (userIds: string[]) => Promise<Channel>;
createGroupChannel: (userIds: string[]) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<any>;
unarchiveChannel: (channelId: string) => Promise<Channel>;
updateChannel: (channel: Channel) => Promise<Channel>;
convertChannelToPrivate: (channelId: string) => Promise<Channel>;
updateChannelPrivacy: (channelId: string, privacy: any) => Promise<Channel>;
patchChannel: (channelId: string, channelPatch: Partial<Channel>) => Promise<Channel>;
updateChannelNotifyProps: (props: ChannelNotifyProps & {channel_id: string, user_id: string}) => Promise<any>;
getChannel: (channelId: string) => Promise<Channel>;
getChannelByName: (teamId: string, channelName: string, includeDeleted?: boolean) => Promise<Channel>;
getChannelByNameAndTeamName: (teamName: string, channelName: string, includeDeleted?: boolean) => Promise<Channel>;
getChannels: (teamId: string, page?: number, perPage?: number) => Promise<Channel[]>;
getArchivedChannels: (teamId: string, page?: number, perPage?: number) => Promise<Channel[]>;
getMyChannels: (teamId: string, includeDeleted?: boolean, lastDeleteAt?: number) => Promise<Channel[]>;
getMyChannelMember: (channelId: string) => Promise<ChannelMembership>;
getMyChannelMembers: (teamId: string) => Promise<ChannelMembership[]>;
getChannelMembers: (channelId: string, page?: number, perPage?: number) => Promise<ChannelMembership[]>;
getChannelTimezones: (channelId: string) => Promise<string[]>;
getChannelMember: (channelId: string, userId: string) => Promise<ChannelMembership>;
getChannelMembersByIds: (channelId: string, userIds: string[]) => Promise<ChannelMembership[]>;
addToChannel: (userId: string, channelId: string, postRootId?: string) => Promise<ChannelMembership>;
removeFromChannel: (userId: string, channelId: string) => Promise<any>;
getChannelStats: (channelId: string) => Promise<ChannelStats>;
getChannelMemberCountsByGroup: (channelId: string, includeTimezones: boolean) => Promise<ChannelMemberCountByGroup[]>;
viewMyChannel: (channelId: string, prevChannelId?: string) => Promise<any>;
autocompleteChannels: (teamId: string, name: string) => Promise<Channel[]>;
autocompleteChannelsForSearch: (teamId: string, name: string) => Promise<Channel[]>;
searchChannels: (teamId: string, term: string) => Promise<Channel[]>;
searchArchivedChannels: (teamId: string, term: string) => Promise<Channel[]>;
}
const ClientChannels = (superclass: any) => class extends superclass {
getAllChannels = async (page = 0, perPage = PER_PAGE_DEFAULT, notAssociatedToGroup = '', excludeDefaultChannels = false, includeTotalCount = false) => {
const queryData = {
page,
per_page: perPage,
not_associated_to_group: notAssociatedToGroup,
exclude_default_channels: excludeDefaultChannels,
include_total_count: includeTotalCount,
};
return this.doFetch(
`${this.getChannelsRoute()}${buildQueryString(queryData)}`,
{method: 'get'},
);
};
createChannel = async (channel: Channel) => {
this.analytics.trackAPI('api_channels_create', {team_id: channel.team_id});
return this.doFetch(
`${this.getChannelsRoute()}`,
{method: 'post', body: JSON.stringify(channel)},
);
};
createDirectChannel = async (userIds: string[]) => {
this.analytics.trackAPI('api_channels_create_direct');
return this.doFetch(
`${this.getChannelsRoute()}/direct`,
{method: 'post', body: JSON.stringify(userIds)},
);
};
createGroupChannel = async (userIds: string[]) => {
this.analytics.trackAPI('api_channels_create_group');
return this.doFetch(
`${this.getChannelsRoute()}/group`,
{method: 'post', body: JSON.stringify(userIds)},
);
};
deleteChannel = async (channelId: string) => {
this.analytics.trackAPI('api_channels_delete', {channel_id: channelId});
return this.doFetch(
`${this.getChannelRoute(channelId)}`,
{method: 'delete'},
);
};
unarchiveChannel = async (channelId: string) => {
this.analytics.trackAPI('api_channels_unarchive', {channel_id: channelId});
return this.doFetch(
`${this.getChannelRoute(channelId)}/restore`,
{method: 'post'},
);
};
updateChannel = async (channel: Channel) => {
this.analytics.trackAPI('api_channels_update', {channel_id: channel.id});
return this.doFetch(
`${this.getChannelRoute(channel.id)}`,
{method: 'put', body: JSON.stringify(channel)},
);
};
convertChannelToPrivate = async (channelId: string) => {
this.analytics.trackAPI('api_channels_convert_to_private', {channel_id: channelId});
return this.doFetch(
`${this.getChannelRoute(channelId)}/convert`,
{method: 'post'},
);
};
updateChannelPrivacy = async (channelId: string, privacy: any) => {
this.analytics.trackAPI('api_channels_update_privacy', {channel_id: channelId, privacy});
return this.doFetch(
`${this.getChannelRoute(channelId)}/privacy`,
{method: 'put', body: JSON.stringify({privacy})},
);
};
patchChannel = async (channelId: string, channelPatch: Partial<Channel>) => {
this.analytics.trackAPI('api_channels_patch', {channel_id: channelId});
return this.doFetch(
`${this.getChannelRoute(channelId)}/patch`,
{method: 'put', body: JSON.stringify(channelPatch)},
);
};
updateChannelNotifyProps = async (props: ChannelNotifyProps & {channel_id: string, user_id: string}) => {
this.analytics.trackAPI('api_users_update_channel_notifications', {channel_id: props.channel_id});
return this.doFetch(
`${this.getChannelMemberRoute(props.channel_id, props.user_id)}/notify_props`,
{method: 'put', body: JSON.stringify(props)},
);
};
getChannel = async (channelId: string) => {
this.analytics.trackAPI('api_channel_get', {channel_id: channelId});
return this.doFetch(
`${this.getChannelRoute(channelId)}`,
{method: 'get'},
);
};
getChannelByName = async (teamId: string, channelName: string, includeDeleted = false) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/channels/name/${channelName}?include_deleted=${includeDeleted}`,
{method: 'get'},
);
};
getChannelByNameAndTeamName = async (teamName: string, channelName: string, includeDeleted = false) => {
this.analytics.trackAPI('api_channel_get_by_name_and_teamName', {channel_name: channelName, team_name: teamName, include_deleted: includeDeleted});
return this.doFetch(
`${this.getTeamNameRoute(teamName)}/channels/name/${channelName}?include_deleted=${includeDeleted}`,
{method: 'get'},
);
};
getChannels = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/channels${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};
getArchivedChannels = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/channels/deleted${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};
getMyChannels = async (teamId: string, includeDeleted = false, lastDeleteAt = 0) => {
return this.doFetch(
`${this.getUserRoute('me')}/teams/${teamId}/channels${buildQueryString({
include_deleted: includeDeleted,
last_delete_at: lastDeleteAt,
})}`,
{method: 'get'},
);
};
getMyChannelMember = async (channelId: string) => {
return this.doFetch(
`${this.getChannelMemberRoute(channelId, 'me')}`,
{method: 'get'},
);
};
getMyChannelMembers = async (teamId: string) => {
return this.doFetch(
`${this.getUserRoute('me')}/teams/${teamId}/channels/members`,
{method: 'get'},
);
};
getChannelMembers = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getChannelMembersRoute(channelId)}${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};
getChannelTimezones = async (channelId: string) => {
return this.doFetch(
`${this.getChannelRoute(channelId)}/timezones`,
{method: 'get'},
);
};
getChannelMember = async (channelId: string, userId: string) => {
return this.doFetch(
`${this.getChannelMemberRoute(channelId, userId)}`,
{method: 'get'},
);
};
getChannelMembersByIds = async (channelId: string, userIds: string[]) => {
return this.doFetch(
`${this.getChannelMembersRoute(channelId)}/ids`,
{method: 'post', body: JSON.stringify(userIds)},
);
};
addToChannel = async (userId: string, channelId: string, postRootId = '') => {
this.analytics.trackAPI('api_channels_add_member', {channel_id: channelId});
const member = {user_id: userId, channel_id: channelId, post_root_id: postRootId};
return this.doFetch(
`${this.getChannelMembersRoute(channelId)}`,
{method: 'post', body: JSON.stringify(member)},
);
};
removeFromChannel = async (userId: string, channelId: string) => {
this.analytics.trackAPI('api_channels_remove_member', {channel_id: channelId});
return this.doFetch(
`${this.getChannelMemberRoute(channelId, userId)}`,
{method: 'delete'},
);
};
getChannelStats = async (channelId: string) => {
return this.doFetch(
`${this.getChannelRoute(channelId)}/stats`,
{method: 'get'},
);
};
getChannelMemberCountsByGroup = async (channelId: string, includeTimezones: boolean) => {
return this.doFetch(
`${this.getChannelRoute(channelId)}/member_counts_by_group?include_timezones=${includeTimezones}`,
{method: 'get'},
);
};
viewMyChannel = async (channelId: string, prevChannelId?: string) => {
const data = {channel_id: channelId, prev_channel_id: prevChannelId};
return this.doFetch(
`${this.getChannelsRoute()}/members/me/view`,
{method: 'post', body: JSON.stringify(data)},
);
};
autocompleteChannels = async (teamId: string, name: string) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/channels/autocomplete${buildQueryString({name})}`,
{method: 'get'},
);
};
autocompleteChannelsForSearch = async (teamId: string, name: string) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/channels/search_autocomplete${buildQueryString({name})}`,
{method: 'get'},
);
};
searchChannels = async (teamId: string, term: string) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/channels/search`,
{method: 'post', body: JSON.stringify({term})},
);
};
searchArchivedChannels = async (teamId: string, term: string) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/channels/search_archived`,
{method: 'post', body: JSON.stringify({term})},
);
};
};
export default ClientChannels;

View file

@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const HEADER_AUTH = 'Authorization';
export const HEADER_BEARER = 'BEARER';
export const HEADER_REQUESTED_WITH = 'X-Requested-With';
export const HEADER_USER_AGENT = 'User-Agent';
export const HEADER_X_CLUSTER_ID = 'X-Cluster-Id';
export const HEADER_X_CSRF_TOKEN = 'X-CSRF-Token';
export const HEADER_TOKEN = 'Token';
export const HEADER_X_VERSION_ID = 'X-Version-Id';
export const DEFAULT_LIMIT_BEFORE = 30;
export const DEFAULT_LIMIT_AFTER = 30;
export const PER_PAGE_DEFAULT = 60;

99
app/client/rest/emojis.ts Normal file
View file

@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
export interface ClientEmojisMix {
createCustomEmoji: (emoji: CustomEmoji, imageData: any) => Promise<CustomEmoji>;
getCustomEmoji: (id: string) => Promise<CustomEmoji>;
getCustomEmojiByName: (name: string) => Promise<CustomEmoji>;
getCustomEmojis: (page?: number, perPage?: number, sort?: string) => Promise<CustomEmoji[]>;
deleteCustomEmoji: (emojiId: string) => Promise<any>;
getSystemEmojiImageUrl: (filename: string) => string;
getCustomEmojiImageUrl: (id: string) => string;
searchCustomEmoji: (term: string, options?: Record<string, any>) => Promise<CustomEmoji[]>;
autocompleteCustomEmoji: (name: string) => Promise<CustomEmoji[]>;
}
const ClientEmojis = (superclass: any) => class extends superclass {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
createCustomEmoji = async (emoji: CustomEmoji, imageData: any) => {
this.analytics.trackAPI('api_emoji_custom_add');
// FIXME: Multipart upload with client
// const formData = new FormData();
// formData.append('image', imageData);
// formData.append('emoji', JSON.stringify(emoji));
// const request: any = {
// method: 'post',
// body: formData,
// };
// if (formData.getBoundary) {
// request.headers = {
// 'Content-Type': `multipart/form-data; boundary=${formData.getBoundary()}`,
// };
// }
// return this.doFetch(
// `${this.getEmojisRoute()}`,
// request,
// );
};
getCustomEmoji = async (id: string) => {
return this.doFetch(
`${this.getEmojisRoute()}/${id}`,
{method: 'get'},
);
};
getCustomEmojiByName = async (name: string) => {
return this.doFetch(
`${this.getEmojisRoute()}/name/${name}`,
{method: 'get'},
);
};
getCustomEmojis = async (page = 0, perPage = PER_PAGE_DEFAULT, sort = '') => {
return this.doFetch(
`${this.getEmojisRoute()}${buildQueryString({page, per_page: perPage, sort})}`,
{method: 'get'},
);
};
deleteCustomEmoji = async (emojiId: string) => {
this.analytics.trackAPI('api_emoji_custom_delete');
return this.doFetch(
`${this.getEmojiRoute(emojiId)}`,
{method: 'delete'},
);
};
getSystemEmojiImageUrl = (filename: string) => {
return `${this.url}/static/emoji/${filename}.png`;
};
getCustomEmojiImageUrl = (id: string) => {
return `${this.getEmojiRoute(id)}/image`;
};
searchCustomEmoji = async (term: string, options = {}) => {
return this.doFetch(
`${this.getEmojisRoute()}/search`,
{method: 'post', body: JSON.stringify({term, ...options})},
);
};
autocompleteCustomEmoji = async (name: string) => {
return this.doFetch(
`${this.getEmojisRoute()}/autocomplete${buildQueryString({name})}`,
{method: 'get'},
);
};
};
export default ClientEmojis;

26
app/client/rest/error.ts Normal file
View file

@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {cleanUrlForLogging} from '@utils/url';
export default class ClientError extends Error {
url: string;
intl: { defaultMessage: string; id: string } | { defaultMessage: string; id: string } | { id: string; defaultMessage: string; values: any } | { id: string; defaultMessage: string };
server_error_id: any;
status_code: any;
details: Error;
constructor(baseUrl: string, data: any) {
super(data.message + ': ' + cleanUrlForLogging(baseUrl, data.url));
this.message = data.message;
this.url = data.url;
this.intl = data.intl;
this.server_error_id = data.server_error_id;
this.status_code = data.status_code;
this.details = data.details;
// Ensure message is treated as a property of this class when object spreading. Without this,
// copying the object by using `{...error}` would not include the message.
Object.defineProperty(this, 'message', {enumerable: true});
}
}

47
app/client/rest/files.ts Normal file
View file

@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface ClientFilesMix {
getFileUrl: (fileId: string, timestamp: number) => string;
getFileThumbnailUrl: (fileId: string, timestamp: number) => string;
getFilePreviewUrl: (fileId: string, timestamp: number) => string;
getFilePublicLink: (fileId: string) => Promise<any>;
}
const ClientFiles = (superclass: any) => class extends superclass {
getFileUrl(fileId: string, timestamp: number) {
let url = `${this.getFileRoute(fileId)}`;
if (timestamp) {
url += `?${timestamp}`;
}
return url;
}
getFileThumbnailUrl(fileId: string, timestamp: number) {
let url = `${this.getFileRoute(fileId)}/thumbnail`;
if (timestamp) {
url += `?${timestamp}`;
}
return url;
}
getFilePreviewUrl(fileId: string, timestamp: number) {
let url = `${this.getFileRoute(fileId)}/preview`;
if (timestamp) {
url += `?${timestamp}`;
}
return url;
}
getFilePublicLink = async (fileId: string) => {
return this.doFetch(
`${this.getFileRoute(fileId)}/link`,
{method: 'get'},
);
}
};
export default ClientFiles;

View file

@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import ClientError from './error';
export interface ClientGeneralMix {
getOpenGraphMetadata: (url: string) => Promise<any>;
ping: () => Promise<any>;
logClientError: (message: string, level?: string) => Promise<any>;
getClientConfigOld: () => Promise<ClientConfig>;
getClientLicenseOld: () => Promise<any>;
getTimezones: () => Promise<string[]>;
getDataRetentionPolicy: () => Promise<any>;
getRolesByNames: (rolesNames: string[]) => Promise<Role[]>;
getRedirectLocation: (urlParam: string) => Promise<Record<string, string>>;
}
const ClientGeneral = (superclass: any) => class extends superclass {
getOpenGraphMetadata = async (url: string) => {
return this.doFetch(
`${this.getBaseRoute()}/opengraph`,
{method: 'post', body: JSON.stringify({url})},
);
};
ping = async () => {
return this.doFetch(
`${this.getBaseRoute()}/system/ping?time=${Date.now()}`,
{method: 'get'},
);
};
logClientError = async (message: string, level = 'ERROR') => {
const url = `${this.getBaseRoute()}/logs`;
if (!this.enableLogging) {
throw new ClientError(this.getUrl(), {
message: 'Logging disabled.',
url,
});
}
return this.doFetch(
url,
{method: 'post', body: JSON.stringify({message, level})},
);
};
getClientConfigOld = async () => {
return this.doFetch(
`${this.getBaseRoute()}/config/client?format=old`,
{method: 'get'},
);
};
getClientLicenseOld = async () => {
return this.doFetch(
`${this.getBaseRoute()}/license/client?format=old`,
{method: 'get'},
);
};
getTimezones = async () => {
return this.doFetch(
`${this.getTimezonesRoute()}`,
{method: 'get'},
);
};
getDataRetentionPolicy = () => {
return this.doFetch(
`${this.getDataRetentionRoute()}/policy`,
{method: 'get'},
);
};
getRolesByNames = async (rolesNames: string[]) => {
return this.doFetch(
`${this.getRolesRoute()}/names`,
{method: 'post', body: JSON.stringify(rolesNames)},
);
};
getRedirectLocation = async (urlParam: string) => {
if (!urlParam.length) {
return Promise.resolve();
}
const url = `${this.getRedirectLocationRoute()}${buildQueryString({url: urlParam})}`;
return this.doFetch(url, {method: 'get'});
};
};
export default ClientGeneral;

52
app/client/rest/groups.ts Normal file
View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
export interface ClientGroupsMix {
getGroups: (filterAllowReference?: boolean, page?: number, perPage?: number) => Promise<Group[]>;
getGroupsByUserId: (userID: string) => Promise<Group[]>;
getAllGroupsAssociatedToTeam: (teamID: string, filterAllowReference?: boolean) => Promise<Group[]>;
getAllGroupsAssociatedToChannelsInTeam: (teamID: string, filterAllowReference?: boolean) => Promise<Group[]>;
getAllGroupsAssociatedToChannel: (channelID: string, filterAllowReference?: boolean) => Promise<Group[]>;
}
const ClientGroups = (superclass: any) => class extends superclass {
getGroups = async (filterAllowReference = false, page = 0, perPage = PER_PAGE_DEFAULT, since = 0) => {
return this.doFetch(
`${this.getBaseRoute()}/groups${buildQueryString({filter_allow_reference: filterAllowReference, page, per_page: perPage, since})}`,
{method: 'get'},
);
};
getGroupsByUserId = async (userID: string) => {
return this.doFetch(
`${this.getUsersRoute()}/${userID}/groups`,
{method: 'get'},
);
}
getAllGroupsAssociatedToTeam = async (teamID: string, filterAllowReference = false) => {
return this.doFetch(
`${this.getBaseRoute()}/teams/${teamID}/groups${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`,
{method: 'get'},
);
};
getAllGroupsAssociatedToChannelsInTeam = async (teamID: string, filterAllowReference = false) => {
return this.doFetch(
`${this.getBaseRoute()}/teams/${teamID}/groups_by_channels${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`,
{method: 'get'},
);
};
getAllGroupsAssociatedToChannel = async (channelID: string, filterAllowReference = false) => {
return this.doFetch(
`${this.getBaseRoute()}/channels/${channelID}/groups${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`,
{method: 'get'},
);
};
};
export default ClientGroups;

View file

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import nock from 'nock';
import {HEADER_X_VERSION_ID} from '@client/rest/constants';
import ClientError from '@client/rest/error';
import TestHelper from '@test/test_helper';
import {isMinimumServerVersion} from '@utils/helpers';
describe('Client4', () => {
beforeAll(() => {
if (!nock.isActive()) {
nock.activate();
}
});
afterAll(() => {
nock.restore();
});
it('should remove trailing slash while trying to setUrl', () => {
const client = TestHelper.createClient();
client.setUrl('http://localhost:8065/');
expect(client.url).toBe('http://localhost:8065');
client.setUrl('http://localhost:8065/company/mattermost/');
expect(client.url).toBe('http://localhost:8065/company/mattermost');
});
describe('doFetchWithResponse', () => {
it('serverVersion should be set from response header', async () => {
const client = TestHelper.createClient();
assert.equal(client.serverVersion, '');
nock(client.getBaseRoute()).
get('/users/me').
reply(200, '{}', {[HEADER_X_VERSION_ID]: '5.0.0.5.0.0.abc123'});
await client.getMe();
assert.equal(client.serverVersion, '5.0.0.5.0.0.abc123');
assert.equal(isMinimumServerVersion(client.serverVersion, 5, 0, 0), true);
assert.equal(isMinimumServerVersion(client.serverVersion, 5, 1, 0), false);
nock(client.getBaseRoute()).
get('/users/me').
reply(200, '{}', {[HEADER_X_VERSION_ID]: '5.3.0.5.3.0.abc123'});
await client.getMe();
assert.equal(client.serverVersion, '5.3.0.5.3.0.abc123');
assert.equal(isMinimumServerVersion(client.serverVersion, 5, 0, 0), true);
assert.equal(isMinimumServerVersion(client.serverVersion, 5, 1, 0), true);
});
});
});
describe('ClientError', () => {
it('standard fields should be enumerable', () => {
const error = new ClientError('https://example.com', {
message: 'This is a message',
intl: {
id: 'test.error',
defaultMessage: 'This is a message with a translation',
},
server_error_id: 'test.app_error',
status_code: 418,
url: 'https://example.com/api/v4/error',
});
const copy = {...error};
assert.strictEqual(copy.message, error.message);
assert.strictEqual(copy.intl, error.intl);
assert.strictEqual(copy.server_error_id, error.server_error_id);
assert.strictEqual(copy.status_code, error.status_code);
assert.strictEqual(copy.url, error.url);
});
});

56
app/client/rest/index.ts Normal file
View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import mix from '@utils/mix';
import {DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE, HEADER_X_VERSION_ID} from './constants';
import ClientApps, {ClientAppsMix} from './apps';
import ClientBase from './base';
import ClientBots, {ClientBotsMix} from './bots';
import ClientChannels, {ClientChannelsMix} from './channels';
import ClientEmojis, {ClientEmojisMix} from './emojis';
import ClientFiles, {ClientFilesMix} from './files';
import ClientGeneral, {ClientGeneralMix} from './general';
import ClientGroups, {ClientGroupsMix} from './groups';
import ClientIntegrations, {ClientIntegrationsMix} from './integrations';
import ClientPosts, {ClientPostsMix} from './posts';
import ClientPreferences, {ClientPreferencesMix} from './preferences';
import ClientTeams, {ClientTeamsMix} from './teams';
import ClientTos, {ClientTosMix} from './tos';
import ClientUsers, {ClientUsersMix} from './users';
interface Client extends ClientBase,
ClientAppsMix,
ClientBotsMix,
ClientChannelsMix,
ClientEmojisMix,
ClientFilesMix,
ClientGeneralMix,
ClientGroupsMix,
ClientIntegrationsMix,
ClientPostsMix,
ClientPreferencesMix,
ClientTeamsMix,
ClientTosMix,
ClientUsersMix
{}
class Client extends mix(ClientBase).with(
ClientApps,
ClientBots,
ClientChannels,
ClientEmojis,
ClientFiles,
ClientGeneral,
ClientGroups,
ClientIntegrations,
ClientPosts,
ClientPreferences,
ClientTeams,
ClientTos,
ClientUsers,
) {}
const Client4 = new Client();
export {Client4, Client, DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE, HEADER_X_VERSION_ID};

View file

@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
export interface ClientIntegrationsMix {
getCommandsList: (teamId: string) => Promise<Command[]>;
getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: Record<string, any>) => Promise<Command[]>;
getAutocompleteCommandsList: (teamId: string, page?: number, perPage?: number) => Promise<Command[]>;
executeCommand: (command: Command, commandArgs?: Record<string, any>) => Promise<any>;
addCommand: (command: Command) => Promise<Command>;
submitInteractiveDialog: (data: DialogSubmission) => Promise<any>;
}
const ClientIntegrations = (superclass: any) => class extends superclass {
getCommandsList = async (teamId: string) => {
return this.doFetch(
`${this.getCommandsRoute()}?team_id=${teamId}`,
{method: 'get'},
);
};
getCommandAutocompleteSuggestionsList = async (userInput: string, teamId: string, commandArgs: {}) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/commands/autocomplete_suggestions${buildQueryString({...commandArgs, user_input: userInput})}`,
{method: 'get'},
);
};
getAutocompleteCommandsList = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/commands/autocomplete${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};
executeCommand = async (command: Command, commandArgs = {}) => {
this.analytics.trackAPI('api_integrations_used');
return this.doFetch(
`${this.getCommandsRoute()}/execute`,
{method: 'post', body: JSON.stringify({command, ...commandArgs})},
);
};
addCommand = async (command: Command) => {
this.analytics.trackAPI('api_integrations_created');
return this.doFetch(
`${this.getCommandsRoute()}`,
{method: 'post', body: JSON.stringify(command)},
);
};
submitInteractiveDialog = async (data: DialogSubmission) => {
this.analytics.trackAPI('api_interactive_messages_dialog_submitted');
return this.doFetch(
`${this.getBaseRoute()}/actions/dialogs/submit`,
{method: 'post', body: JSON.stringify(data)},
);
};
};
export default ClientIntegrations;

234
app/client/rest/posts.ts Normal file
View file

@ -0,0 +1,234 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
export interface ClientPostsMix {
createPost: (post: Post) => Promise<Post>;
updatePost: (post: Post) => Promise<Post>;
getPost: (postId: string) => Promise<Post>;
patchPost: (postPatch: Partial<Post> & {id: string}) => Promise<Post>;
deletePost: (postId: string) => Promise<any>;
getPostThread: (postId: string) => Promise<any>;
getPosts: (channelId: string, page?: number, perPage?: number) => Promise<any>;
getPostsSince: (channelId: string, since: number) => Promise<any>;
getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<any>;
getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<any>;
getFileInfosForPost: (postId: string) => Promise<FileInfo[]>;
getFlaggedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise<any>;
getPinnedPosts: (channelId: string) => Promise<any>;
markPostAsUnread: (userId: string, postId: string) => Promise<any>;
pinPost: (postId: string) => Promise<any>;
unpinPost: (postId: string) => Promise<any>;
addReaction: (userId: string, postId: string, emojiName: string) => Promise<any>;
removeReaction: (userId: string, postId: string, emojiName: string) => Promise<any>;
getReactionsForPost: (postId: string) => Promise<any>;
searchPostsWithParams: (teamId: string, params: any) => Promise<any>;
searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise<any>;
doPostAction: (postId: string, actionId: string, selectedOption?: string) => Promise<any>;
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<any>;
}
const ClientPosts = (superclass: any) => class extends superclass {
createPost = async (post: Post) => {
this.analytics.trackAPI('api_posts_create', {channel_id: post.channel_id});
if (post.root_id != null && post.root_id !== '') {
this.analytics.trackAPI('api_posts_replied', {channel_id: post.channel_id});
}
return this.doFetch(
`${this.getPostsRoute()}`,
{method: 'post', body: JSON.stringify(post)},
);
};
updatePost = async (post: Post) => {
this.analytics.trackAPI('api_posts_update', {channel_id: post.channel_id});
return this.doFetch(
`${this.getPostRoute(post.id)}`,
{method: 'put', body: JSON.stringify(post)},
);
};
getPost = async (postId: string) => {
return this.doFetch(
`${this.getPostRoute(postId)}`,
{method: 'get'},
);
};
patchPost = async (postPatch: Partial<Post> & {id: string}) => {
this.analytics.trackAPI('api_posts_patch', {channel_id: postPatch.channel_id});
return this.doFetch(
`${this.getPostRoute(postPatch.id)}/patch`,
{method: 'put', body: JSON.stringify(postPatch)},
);
};
deletePost = async (postId: string) => {
this.analytics.trackAPI('api_posts_delete');
return this.doFetch(
`${this.getPostRoute(postId)}`,
{method: 'delete'},
);
};
getPostThread = async (postId: string) => {
return this.doFetch(
`${this.getPostRoute(postId)}/thread`,
{method: 'get'},
);
};
getPosts = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getChannelRoute(channelId)}/posts${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};
getPostsSince = async (channelId: string, since: number) => {
return this.doFetch(
`${this.getChannelRoute(channelId)}/posts${buildQueryString({since})}`,
{method: 'get'},
);
};
getPostsBefore = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
this.analytics.trackAPI('api_posts_get_before', {channel_id: channelId});
return this.doFetch(
`${this.getChannelRoute(channelId)}/posts${buildQueryString({before: postId, page, per_page: perPage})}`,
{method: 'get'},
);
};
getPostsAfter = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
this.analytics.trackAPI('api_posts_get_after', {channel_id: channelId});
return this.doFetch(
`${this.getChannelRoute(channelId)}/posts${buildQueryString({after: postId, page, per_page: perPage})}`,
{method: 'get'},
);
};
getFileInfosForPost = async (postId: string) => {
return this.doFetch(
`${this.getPostRoute(postId)}/files/info`,
{method: 'get'},
);
};
getFlaggedPosts = async (userId: string, channelId = '', teamId = '', page = 0, perPage = PER_PAGE_DEFAULT) => {
this.analytics.trackAPI('api_posts_get_flagged', {team_id: teamId});
return this.doFetch(
`${this.getUserRoute(userId)}/posts/flagged${buildQueryString({channel_id: channelId, team_id: teamId, page, per_page: perPage})}`,
{method: 'get'},
);
};
getPinnedPosts = async (channelId: string) => {
this.analytics.trackAPI('api_posts_get_pinned', {channel_id: channelId});
return this.doFetch(
`${this.getChannelRoute(channelId)}/pinned`,
{method: 'get'},
);
};
markPostAsUnread = async (userId: string, postId: string) => {
this.analytics.trackAPI('api_post_set_unread_post');
return this.doFetch(
`${this.getUserRoute(userId)}/posts/${postId}/set_unread`,
{method: 'post'},
);
}
pinPost = async (postId: string) => {
this.analytics.trackAPI('api_posts_pin');
return this.doFetch(
`${this.getPostRoute(postId)}/pin`,
{method: 'post'},
);
};
unpinPost = async (postId: string) => {
this.analytics.trackAPI('api_posts_unpin');
return this.doFetch(
`${this.getPostRoute(postId)}/unpin`,
{method: 'post'},
);
};
addReaction = async (userId: string, postId: string, emojiName: string) => {
this.analytics.trackAPI('api_reactions_save', {post_id: postId});
return this.doFetch(
`${this.getReactionsRoute()}`,
{method: 'post', body: JSON.stringify({user_id: userId, post_id: postId, emoji_name: emojiName})},
);
};
removeReaction = async (userId: string, postId: string, emojiName: string) => {
this.analytics.trackAPI('api_reactions_delete', {post_id: postId});
return this.doFetch(
`${this.getUserRoute(userId)}/posts/${postId}/reactions/${emojiName}`,
{method: 'delete'},
);
};
getReactionsForPost = async (postId: string) => {
return this.doFetch(
`${this.getPostRoute(postId)}/reactions`,
{method: 'get'},
);
};
searchPostsWithParams = async (teamId: string, params: any) => {
this.analytics.trackAPI('api_posts_search', {team_id: teamId});
return this.doFetch(
`${this.getTeamRoute(teamId)}/posts/search`,
{method: 'post', body: JSON.stringify(params)},
);
};
searchPosts = async (teamId: string, terms: string, isOrSearch: boolean) => {
return this.searchPostsWithParams(teamId, {terms, is_or_search: isOrSearch});
};
doPostAction = async (postId: string, actionId: string, selectedOption = '') => {
return this.doPostActionWithCookie(postId, actionId, '', selectedOption);
};
doPostActionWithCookie = async (postId: string, actionId: string, actionCookie: string, selectedOption = '') => {
if (selectedOption) {
this.analytics.trackAPI('api_interactive_messages_menu_selected');
} else {
this.analytics.trackAPI('api_interactive_messages_button_clicked');
}
const msg: any = {
selected_option: selectedOption,
};
if (actionCookie !== '') {
msg.cookie = actionCookie;
}
return this.doFetch(
`${this.getPostRoute(postId)}/actions/${encodeURIComponent(actionId)}`,
{method: 'post', body: JSON.stringify(msg)},
);
};
};
export default ClientPosts;

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface ClientPreferencesMix {
savePreferences: (userId: string, preferences: PreferenceType[]) => Promise<any>;
deletePreferences: (userId: string, preferences: PreferenceType[]) => Promise<any>;
getMyPreferences: () => Promise<PreferenceType>;
}
const ClientPreferences = (superclass: any) => class extends superclass {
savePreferences = async (userId: string, preferences: PreferenceType[]) => {
return this.doFetch(
`${this.getPreferencesRoute(userId)}`,
{method: 'put', body: JSON.stringify(preferences)},
);
};
getMyPreferences = async () => {
return this.doFetch(
`${this.getPreferencesRoute('me')}`,
{method: 'get'},
);
};
deletePreferences = async (userId: string, preferences: PreferenceType[]) => {
return this.doFetch(
`${this.getPreferencesRoute(userId)}/delete`,
{method: 'post', body: JSON.stringify(preferences)},
);
};
};
export default ClientPreferences;

175
app/client/rest/teams.ts Normal file
View file

@ -0,0 +1,175 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
export interface ClientTeamsMix {
createTeam: (team: Team) => Promise<Team>;
deleteTeam: (teamId: string) => Promise<any>;
updateTeam: (team: Team) => Promise<Team>;
patchTeam: (team: Partial<Team> & {id: string}) => Promise<Team>;
getTeams: (page?: number, perPage?: number, includeTotalCount?: boolean) => Promise<any>;
getTeam: (teamId: string) => Promise<Team>;
getTeamByName: (teamName: string) => Promise<Team>;
getMyTeams: () => Promise<Team[]>;
getTeamsForUser: (userId: string) => Promise<Team[]>;
getMyTeamMembers: () => Promise<TeamMembership[]>;
getMyTeamUnreads: () => Promise<TeamUnread[]>;
getTeamMembers: (teamId: string, page?: number, perPage?: number) => Promise<TeamMembership[]>;
getTeamMember: (teamId: string, userId: string) => Promise<TeamMembership>;
addToTeam: (teamId: string, userId: string) => Promise<TeamMembership>;
joinTeam: (inviteId: string) => Promise<TeamMembership>;
removeFromTeam: (teamId: string, userId: string) => Promise<any>;
getTeamStats: (teamId: string) => Promise<any>;
getTeamIconUrl: (teamId: string, lastTeamIconUpdate: number) => string;
}
const ClientTeams = (superclass: any) => class extends superclass {
createTeam = async (team: Team) => {
this.analytics.trackAPI('api_teams_create');
return this.doFetch(
`${this.getTeamsRoute()}`,
{method: 'post', body: JSON.stringify(team)},
);
};
deleteTeam = async (teamId: string) => {
this.analytics.trackAPI('api_teams_delete');
return this.doFetch(
`${this.getTeamRoute(teamId)}`,
{method: 'delete'},
);
};
updateTeam = async (team: Team) => {
this.analytics.trackAPI('api_teams_update_name', {team_id: team.id});
return this.doFetch(
`${this.getTeamRoute(team.id)}`,
{method: 'put', body: JSON.stringify(team)},
);
};
patchTeam = async (team: Partial<Team> & {id: string}) => {
this.analytics.trackAPI('api_teams_patch_name', {team_id: team.id});
return this.doFetch(
`${this.getTeamRoute(team.id)}/patch`,
{method: 'put', body: JSON.stringify(team)},
);
};
getTeams = async (page = 0, perPage = PER_PAGE_DEFAULT, includeTotalCount = false) => {
return this.doFetch(
`${this.getTeamsRoute()}${buildQueryString({page, per_page: perPage, include_total_count: includeTotalCount})}`,
{method: 'get'},
);
};
getTeam = async (teamId: string) => {
return this.doFetch(
this.getTeamRoute(teamId),
{method: 'get'},
);
};
getTeamByName = async (teamName: string) => {
this.analytics.trackAPI('api_teams_get_team_by_name');
return this.doFetch(
this.getTeamNameRoute(teamName),
{method: 'get'},
);
};
getMyTeams = async () => {
return this.doFetch(
`${this.getUserRoute('me')}/teams`,
{method: 'get'},
);
};
getTeamsForUser = async (userId: string) => {
return this.doFetch(
`${this.getUserRoute(userId)}/teams`,
{method: 'get'},
);
};
getMyTeamMembers = async () => {
return this.doFetch(
`${this.getUserRoute('me')}/teams/members`,
{method: 'get'},
);
};
getMyTeamUnreads = async () => {
return this.doFetch(
`${this.getUserRoute('me')}/teams/unread`,
{method: 'get'},
);
};
getTeamMembers = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch(
`${this.getTeamMembersRoute(teamId)}${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};
getTeamMember = async (teamId: string, userId: string) => {
return this.doFetch(
`${this.getTeamMemberRoute(teamId, userId)}`,
{method: 'get'},
);
};
addToTeam = async (teamId: string, userId: string) => {
this.analytics.trackAPI('api_teams_invite_members', {team_id: teamId});
const member = {user_id: userId, team_id: teamId};
return this.doFetch(
`${this.getTeamMembersRoute(teamId)}`,
{method: 'post', body: JSON.stringify(member)},
);
};
joinTeam = async (inviteId: string) => {
const query = buildQueryString({invite_id: inviteId});
return this.doFetch(
`${this.getTeamsRoute()}/members/invite${query}`,
{method: 'post'},
);
};
removeFromTeam = async (teamId: string, userId: string) => {
this.analytics.trackAPI('api_teams_remove_members', {team_id: teamId});
return this.doFetch(
`${this.getTeamMemberRoute(teamId, userId)}`,
{method: 'delete'},
);
};
getTeamStats = async (teamId: string) => {
return this.doFetch(
`${this.getTeamRoute(teamId)}/stats`,
{method: 'get'},
);
};
getTeamIconUrl = (teamId: string, lastTeamIconUpdate: number) => {
const params: any = {};
if (lastTeamIconUpdate) {
params._ = lastTeamIconUpdate;
}
return `${this.getTeamRoute(teamId)}/image${buildQueryString(params)}`;
};
};
export default ClientTeams;

25
app/client/rest/tos.ts Normal file
View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface ClientTosMix {
updateMyTermsOfServiceStatus: (termsOfServiceId: string, accepted: boolean) => Promise<any>;
getTermsOfService: () => Promise<any>;
}
const ClientTos = (superclass: any) => class extends superclass {
updateMyTermsOfServiceStatus = async (termsOfServiceId: string, accepted: boolean) => {
return this.doFetch(
`${this.getUserRoute('me')}/terms_of_service`,
{method: 'post', body: JSON.stringify({termsOfServiceId, accepted})},
);
}
getTermsOfService = async () => {
return this.doFetch(
`${this.getBaseRoute()}/terms_of_service`,
{method: 'get'},
);
}
};
export default ClientTos;

396
app/client/rest/users.ts Normal file
View file

@ -0,0 +1,396 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General} from '@constants';
import {buildQueryString, isMinimumServerVersion} from '@utils/helpers';
import {PER_PAGE_DEFAULT} from './constants';
export interface ClientUsersMix {
createUser: (user: UserProfile, token: string, inviteId: string) => Promise<UserProfile>;
patchMe: (userPatch: Partial<UserProfile>) => Promise<UserProfile>;
patchUser: (userPatch: Partial<UserProfile> & {id: string}) => Promise<UserProfile>;
updateUser: (user: UserProfile) => Promise<UserProfile>;
demoteUserToGuest: (userId: string) => Promise<any>;
getKnownUsers: () => Promise<string[]>;
sendPasswordResetEmail: (email: string) => Promise<any>;
setDefaultProfileImage: (userId: string) => Promise<any>;
login: (loginId: string, password: string, token?: string, deviceId?: string, ldapOnly?: boolean) => Promise<UserProfile>;
loginById: (id: string, password: string, token?: string, deviceId?: string) => Promise<UserProfile>;
logout: () => Promise<any>;
getProfiles: (page?: number, perPage?: number, options?: Record<string, any>) => Promise<UserProfile[]>;
getProfilesByIds: (userIds: string[], options?: Record<string, any>) => Promise<UserProfile[]>;
getProfilesByUsernames: (usernames: string[]) => Promise<UserProfile[]>;
getProfilesInTeam: (teamId: string, page?: number, perPage?: number, sort?: string, options?: Record<string, any>) => Promise<UserProfile[]>;
getProfilesNotInTeam: (teamId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise<UserProfile[]>;
getProfilesWithoutTeam: (page?: number, perPage?: number, options?: Record<string, any>) => Promise<UserProfile[]>;
getProfilesInChannel: (channelId: string, page?: number, perPage?: number, sort?: string) => Promise<UserProfile[]>;
getProfilesInGroupChannels: (channelsIds: string[]) => Promise<{[x: string]: UserProfile[]}>;
getProfilesNotInChannel: (teamId: string, channelId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise<UserProfile[]>;
getMe: () => Promise<UserProfile>;
getUser: (userId: string) => Promise<UserProfile>;
getUserByUsername: (username: string) => Promise<UserProfile>;
getUserByEmail: (email: string) => Promise<UserProfile>;
getProfilePictureUrl: (userId: string, lastPictureUpdate: number) => string;
getDefaultProfilePictureUrl: (userId: string) => string;
autocompleteUsers: (name: string, teamId: string, channelId: string, options?: Record<string, any>) => Promise<{users: UserProfile[], out_of_channel?: UserProfile[]}>;
getSessions: (userId: string) => Promise<any>;
checkUserMfa: (loginId: string) => Promise<{mfa_required: boolean}>;
attachDevice: (deviceId: string) => Promise<any>;
searchUsers: (term: string, options: any) => Promise<UserProfile[]>;
getStatusesByIds: (userIds: string[]) => Promise<UserStatus[]>;
getStatus: (userId: string) => Promise<UserStatus>;
updateStatus: (status: UserStatus) => Promise<UserStatus>;
}
const ClientUsers = (superclass: any) => class extends superclass {
createUser = async (user: UserProfile, token: string, inviteId: string) => {
this.analytics.trackAPI('api_users_create');
const queryParams: any = {};
if (token) {
queryParams.t = token;
}
if (inviteId) {
queryParams.iid = inviteId;
}
return this.doFetch(
`${this.getUsersRoute()}${buildQueryString(queryParams)}`,
{method: 'post', body: JSON.stringify(user)},
);
}
patchMe = async (userPatch: Partial<UserProfile>) => {
return this.doFetch(
`${this.getUserRoute('me')}/patch`,
{method: 'put', body: JSON.stringify(userPatch)},
);
}
patchUser = async (userPatch: Partial<UserProfile> & {id: string}) => {
this.analytics.trackAPI('api_users_patch');
return this.doFetch(
`${this.getUserRoute(userPatch.id)}/patch`,
{method: 'put', body: JSON.stringify(userPatch)},
);
}
updateUser = async (user: UserProfile) => {
this.analytics.trackAPI('api_users_update');
return this.doFetch(
`${this.getUserRoute(user.id)}`,
{method: 'put', body: JSON.stringify(user)},
);
}
demoteUserToGuest = async (userId: string) => {
this.analytics.trackAPI('api_users_demote_user_to_guest');
return this.doFetch(
`${this.getUserRoute(userId)}/demote`,
{method: 'post'},
);
}
getKnownUsers = async () => {
this.analytics.trackAPI('api_get_known_users');
return this.doFetch(
`${this.getUsersRoute()}/known`,
{method: 'get'},
);
}
sendPasswordResetEmail = async (email: string) => {
this.analytics.trackAPI('api_users_send_password_reset');
return this.doFetch(
`${this.getUsersRoute()}/password/reset/send`,
{method: 'post', body: JSON.stringify({email})},
);
}
setDefaultProfileImage = async (userId: string) => {
this.analytics.trackAPI('api_users_set_default_profile_picture');
return this.doFetch(
`${this.getUserRoute(userId)}/image`,
{method: 'delete'},
);
};
login = async (loginId: string, password: string, token = '', deviceId = '', ldapOnly = false) => {
this.analytics.trackAPI('api_users_login');
if (ldapOnly) {
this.analytics.trackAPI('api_users_login_ldap');
}
const body: any = {
device_id: deviceId,
login_id: loginId,
password,
token,
};
if (ldapOnly) {
body.ldap_only = 'true';
}
const {data} = await this.doFetchWithResponse(
`${this.getUsersRoute()}/login`,
{
method: 'post',
body: JSON.stringify(body),
headers: {'Cache-Control': 'no-store'},
},
);
return data;
};
loginById = async (id: string, password: string, token = '', deviceId = '') => {
this.analytics.trackAPI('api_users_login');
const body: any = {
device_id: deviceId,
id,
password,
token,
};
const {data} = await this.doFetchWithResponse(
`${this.getUsersRoute()}/login`,
{method: 'post', body: JSON.stringify(body)},
);
return data;
};
logout = async () => {
this.analytics.trackAPI('api_users_logout');
const {response} = await this.doFetchWithResponse(
`${this.getUsersRoute()}/logout`,
{method: 'post'},
);
if (response.ok) {
this.token = '';
}
this.serverVersion = '';
return response;
};
getProfiles = async (page = 0, perPage = PER_PAGE_DEFAULT, options = {}) => {
this.analytics.trackAPI('api_profiles_get');
return this.doFetch(
`${this.getUsersRoute()}${buildQueryString({page, per_page: perPage, ...options})}`,
{method: 'get'},
);
};
getProfilesByIds = async (userIds: string[], options = {}) => {
this.analytics.trackAPI('api_profiles_get_by_ids');
return this.doFetch(
`${this.getUsersRoute()}/ids${buildQueryString(options)}`,
{method: 'post', body: JSON.stringify(userIds)},
);
};
getProfilesByUsernames = async (usernames: string[]) => {
this.analytics.trackAPI('api_profiles_get_by_usernames');
return this.doFetch(
`${this.getUsersRoute()}/usernames`,
{method: 'post', body: JSON.stringify(usernames)},
);
};
getProfilesInTeam = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT, sort = '', options = {}) => {
this.analytics.trackAPI('api_profiles_get_in_team', {team_id: teamId, sort});
return this.doFetch(
`${this.getUsersRoute()}${buildQueryString({...options, in_team: teamId, page, per_page: perPage, sort})}`,
{method: 'get'},
);
};
getProfilesNotInTeam = async (teamId: string, groupConstrained: boolean, page = 0, perPage = PER_PAGE_DEFAULT) => {
this.analytics.trackAPI('api_profiles_get_not_in_team', {team_id: teamId, group_constrained: groupConstrained});
const queryStringObj: any = {not_in_team: teamId, page, per_page: perPage};
if (groupConstrained) {
queryStringObj.group_constrained = true;
}
return this.doFetch(
`${this.getUsersRoute()}${buildQueryString(queryStringObj)}`,
{method: 'get'},
);
};
getProfilesWithoutTeam = async (page = 0, perPage = PER_PAGE_DEFAULT, options = {}) => {
this.analytics.trackAPI('api_profiles_get_without_team');
return this.doFetch(
`${this.getUsersRoute()}${buildQueryString({...options, without_team: 1, page, per_page: perPage})}`,
{method: 'get'},
);
};
getProfilesInChannel = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT, sort = '') => {
this.analytics.trackAPI('api_profiles_get_in_channel', {channel_id: channelId});
const serverVersion = this.getServerVersion();
let queryStringObj;
if (isMinimumServerVersion(serverVersion, 4, 7)) {
queryStringObj = {in_channel: channelId, page, per_page: perPage, sort};
} else {
queryStringObj = {in_channel: channelId, page, per_page: perPage};
}
return this.doFetch(
`${this.getUsersRoute()}${buildQueryString(queryStringObj)}`,
{method: 'get'},
);
};
getProfilesInGroupChannels = async (channelsIds: string[]) => {
this.analytics.trackAPI('api_profiles_get_in_group_channels', {channelsIds});
return this.doFetch(
`${this.getUsersRoute()}/group_channels`,
{method: 'post', body: JSON.stringify(channelsIds)},
);
};
getProfilesNotInChannel = async (teamId: string, channelId: string, groupConstrained: boolean, page = 0, perPage = PER_PAGE_DEFAULT) => {
this.analytics.trackAPI('api_profiles_get_not_in_channel', {team_id: teamId, channel_id: channelId, group_constrained: groupConstrained});
const queryStringObj: any = {in_team: teamId, not_in_channel: channelId, page, per_page: perPage};
if (groupConstrained) {
queryStringObj.group_constrained = true;
}
return this.doFetch(
`${this.getUsersRoute()}${buildQueryString(queryStringObj)}`,
{method: 'get'},
);
};
getMe = async () => {
return this.doFetch(
`${this.getUserRoute('me')}`,
{method: 'get'},
);
};
getUser = async (userId: string) => {
return this.doFetch(
`${this.getUserRoute(userId)}`,
{method: 'get'},
);
};
getUserByUsername = async (username: string) => {
return this.doFetch(
`${this.getUsersRoute()}/username/${username}`,
{method: 'get'},
);
};
getUserByEmail = async (email: string) => {
return this.doFetch(
`${this.getUsersRoute()}/email/${email}`,
{method: 'get'},
);
};
getProfilePictureUrl = (userId: string, lastPictureUpdate: number) => {
const params: any = {};
if (lastPictureUpdate) {
params._ = lastPictureUpdate;
}
return `${this.getUserRoute(userId)}/image${buildQueryString(params)}`;
};
getDefaultProfilePictureUrl = (userId: string) => {
return `${this.getUserRoute(userId)}/image/default`;
};
autocompleteUsers = async (name: string, teamId: string, channelId: string, options = {
limit: General.AUTOCOMPLETE_LIMIT_DEFAULT,
}) => {
return this.doFetch(`${this.getUsersRoute()}/autocomplete${buildQueryString({
in_team: teamId,
in_channel: channelId,
name,
limit: options.limit,
})}`, {
method: 'get',
});
};
getSessions = async (userId: string) => {
return this.doFetch(
`${this.getUserRoute(userId)}/sessions`,
{method: 'get'},
);
};
checkUserMfa = async (loginId: string) => {
return this.doFetch(
`${this.getUsersRoute()}/mfa`,
{method: 'post', body: JSON.stringify({login_id: loginId})},
);
};
attachDevice = async (deviceId: string) => {
return this.doFetch(
`${this.getUsersRoute()}/sessions/device`,
{method: 'put', body: JSON.stringify({device_id: deviceId})},
);
};
searchUsers = async (term: string, options: any) => {
this.analytics.trackAPI('api_search_users');
return this.doFetch(
`${this.getUsersRoute()}/search`,
{method: 'post', body: JSON.stringify({term, ...options})},
);
};
getStatusesByIds = async (userIds: string[]) => {
return this.doFetch(
`${this.getUsersRoute()}/status/ids`,
{method: 'post', body: JSON.stringify(userIds)},
);
};
getStatus = async (userId: string) => {
return this.doFetch(
`${this.getUserRoute(userId)}/status`,
{method: 'get'},
);
};
updateStatus = async (status: UserStatus) => {
return this.doFetch(
`${this.getUserRoute(status.user_id)}/status`,
{method: 'put', body: JSON.stringify(status)},
);
};
};
export default ClientUsers;

View file

@ -0,0 +1,247 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
const MAX_WEBSOCKET_FAILS = 7;
const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec
const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins
class WebSocketClient {
conn?: WebSocket;
connectionUrl: string;
token: string|null;
sequence: number;
connectFailCount: number;
eventCallback?: Function;
firstConnectCallback?: Function;
reconnectCallback?: Function;
errorCallback?: Function;
closeCallback?: Function;
connectingCallback?: Function;
stop: boolean;
connectionTimeout: any;
constructor() {
this.connectionUrl = '';
this.token = null;
this.sequence = 1;
this.connectFailCount = 0;
this.stop = false;
}
initialize(token: string|null, opts = {}) {
const defaults = {
forceConnection: true,
connectionUrl: this.connectionUrl,
};
const {connectionUrl, forceConnection, ...additionalOptions} = Object.assign({}, defaults, opts);
if (forceConnection) {
this.stop = false;
}
return new Promise((resolve, reject) => {
if (this.conn) {
resolve(null);
return;
}
if (connectionUrl == null) {
console.log('websocket must have connection url'); //eslint-disable-line no-console
reject(new Error('websocket must have connection url'));
return;
}
if (this.connectFailCount === 0) {
console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console
}
if (this.connectingCallback) {
this.connectingCallback();
}
const regex = /^(?:https?|wss?):(?:\/\/)?[^/]*/;
const captured = (regex).exec(connectionUrl);
let origin;
if (captured) {
origin = captured[0];
if (Platform.OS === 'android') {
// this is done cause for android having the port 80 or 443 will fail the connection
// the websocket will append them
const split = origin.split(':');
const port = split[2];
if (port === '80' || port === '443') {
origin = `${split[0]}:${split[1]}`;
}
}
} else {
// If we're unable to set the origin header, the websocket won't connect, but the URL is likely malformed anyway
const errorMessage = 'websocket failed to parse origin from ' + connectionUrl;
console.warn(errorMessage); // eslint-disable-line no-console
reject(new Error(errorMessage));
return;
}
this.conn = new WebSocket(connectionUrl, [], {headers: {origin}, ...(additionalOptions || {})});
this.connectionUrl = connectionUrl;
this.token = token;
this.conn!.onopen = () => {
if (token) {
// we check for the platform as a workaround until we fix on the server that further authentications
// are ignored
this.sendMessage('authentication_challenge', {token});
}
if (this.connectFailCount > 0) {
console.log('websocket re-established connection'); //eslint-disable-line no-console
if (this.reconnectCallback) {
this.reconnectCallback();
}
} else if (this.firstConnectCallback) {
this.firstConnectCallback();
}
this.connectFailCount = 0;
resolve(null);
};
this.conn!.onclose = () => {
this.conn = undefined;
this.sequence = 1;
if (this.connectFailCount === 0) {
console.log('websocket closed'); //eslint-disable-line no-console
}
this.connectFailCount++;
if (this.closeCallback) {
this.closeCallback(this.connectFailCount);
}
let retryTime = MIN_WEBSOCKET_RETRY_TIME;
// If we've failed a bunch of connections then start backing off
if (this.connectFailCount > MAX_WEBSOCKET_FAILS) {
retryTime = MIN_WEBSOCKET_RETRY_TIME * this.connectFailCount;
if (retryTime > MAX_WEBSOCKET_RETRY_TIME) {
retryTime = MAX_WEBSOCKET_RETRY_TIME;
}
}
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
}
this.connectionTimeout = setTimeout(
() => {
if (this.stop) {
clearTimeout(this.connectionTimeout);
return;
}
this.initialize(token, opts);
},
retryTime,
);
};
this.conn!.onerror = (evt: any) => {
if (this.connectFailCount <= 1) {
console.log('websocket error'); //eslint-disable-line no-console
console.log(evt); //eslint-disable-line no-console
}
if (this.errorCallback) {
this.errorCallback(evt);
}
};
this.conn!.onmessage = (evt: any) => {
const msg = JSON.parse(evt.data);
if (msg.seq_reply) {
if (msg.error) {
console.warn(msg); //eslint-disable-line no-console
}
} else if (this.eventCallback) {
this.eventCallback(msg);
}
};
});
}
setConnectingCallback(callback: Function) {
this.connectingCallback = callback;
}
setEventCallback(callback: Function) {
this.eventCallback = callback;
}
setFirstConnectCallback(callback: Function) {
this.firstConnectCallback = callback;
}
setReconnectCallback(callback: Function) {
this.reconnectCallback = callback;
}
setErrorCallback(callback: Function) {
this.errorCallback = callback;
}
setCloseCallback(callback: Function) {
this.closeCallback = callback;
}
close(stop = false) {
this.stop = stop;
this.connectFailCount = 0;
this.sequence = 1;
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
this.conn.onclose = () => {}; //eslint-disable-line @typescript-eslint/no-empty-function
this.conn.close();
this.conn = undefined;
console.log('websocket closed'); //eslint-disable-line no-console
}
}
sendMessage(action: string, data: any) {
const msg = {
action,
seq: this.sequence++,
data,
};
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
this.conn.send(JSON.stringify(msg));
} else if (!this.conn || this.conn.readyState === WebSocket.CLOSED) {
this.conn = undefined;
this.initialize(this.token);
}
}
userTyping(channelId: string, parentId: string) {
this.sendMessage('user_typing', {
channel_id: channelId,
parent_id: parentId,
});
}
getStatuses() {
this.sendMessage('get_statuses', null);
}
getStatusesByIds(userIds: string[]) {
this.sendMessage('get_statuses_by_ids', {
user_ids: userIds,
});
}
}
export default new WebSocketClient();

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import FormattedText from '@components/formatted_text';
const style = StyleSheet.create({
info: {
alignItems: 'center',
justifyContent: 'flex-end',
},
version: {
fontSize: 12,
},
});
const AppVersion = () => {
return (
<View pointerEvents='none'>
<View style={style.info}>
<FormattedText
id='mobile.about.appVersion'
defaultMessage='App Version: {version} (Build {number})'
style={style.version}
values={{
version: DeviceInfo.getVersion(),
number: DeviceInfo.getBuildNumber(),
}}
/>
</View>
</View>
);
};
export default AppVersion;

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ErrorText should match snapshot 1`] = `
<Text
style={
Array [
Object {
"color": "#fd5960",
"fontSize": 12,
"marginBottom": 15,
"marginTop": 15,
"textAlign": "left",
},
Object {
"fontSize": 14,
"marginHorizontal": 15,
},
]
}
testID="error.text"
>
Username must begin with a letter and contain between 3 and 22 characters including numbers, lowercase letters, and the symbols
</Text>
`;

View file

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {render} from '@testing-library/react-native';
import {Preferences} from '@constants';
import ErrorText from './index';
describe('ErrorText', () => {
const baseProps = {
testID: 'error.text',
textStyle: {
fontSize: 14,
marginHorizontal: 15,
},
theme: Preferences.THEMES.default,
error: 'Username must begin with a letter and contain between 3 and 22 characters including numbers, lowercase letters, and the symbols',
};
test('should match snapshot', () => {
const wrapper = render(
<ErrorText {...baseProps}/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
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;
testID?: string;
textStyle?: StyleProp<ViewStyle> | StyleProp<TextStyle>
theme: Theme;
}
const ErrorText = ({error, testID, textStyle, theme}: ErrorProps) => {
const style = getStyleSheet(theme);
const message = typeof (error) === 'string' ? error : error.message;
if (typeof (error) !== 'string' && error.intl) {
const {intl} = error;
return (
<FormattedText
testID={testID}
id={intl.id}
defaultMessage={intl.defaultMessage}
values={intl.values}
style={[style.errorLabel, textStyle]}
/>
);
}
return (
<Text
testID={testID}
style={[style.errorLabel, textStyle]}
>
{message}
</Text>
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
errorLabel: {
color: (theme?.errorTextColor || '#DA4A4A'),
marginTop: 15,
marginBottom: 15,
fontSize: 12,
textAlign: 'left',
},
};
});
export default ErrorText;

View file

@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createElement, isValidElement} from 'react';
import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native';
import {useIntl} from 'react-intl';
type FormattedTextProps = {
id: string;
defaultMessage: string;
values?: Record<string, any>;
testID?: string;
style?: StyleProp<ViewStyle> | StyleProp<TextStyle>
}
const FormattedText = (props: FormattedTextProps) => {
const intl = useIntl();
const {formatMessage} = intl;
const {id, defaultMessage, values, ...otherProps} = props;
const tokenizedValues: Record<string, any> = {};
const elements: Record<string, any> = {};
let tokenDelimiter = '';
if (values && Object.keys(values).length > 0) {
// Creates a token with a random UID that should not be guessable or
// conflict with other parts of the `message` string.
const uid = Math.floor(Math.random() * 0x10000000000).toString(16);
const generateToken = (() => {
let counter = 0;
return () => {
const elementId = `ELEMENT-${uid}-${(counter += 1)}`;
return elementId;
};
})();
// Splitting with a delimiter to support IE8. When using a regex
// with a capture group IE8 does not include the capture group in
// the resulting array.
tokenDelimiter = `@__${uid}__@`;
// Iterates over the `props` to keep track of any React Element
// values so they can be represented by the `token` as a placeholder
// when the `message` is formatted. This allows the formatted
// message to then be broken-up into parts with references to the
// React Elements inserted back in.
Object.keys(values).forEach((name) => {
const value = values[name];
if (isValidElement(value)) {
const token = generateToken();
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;
elements[token] = value;
} else {
tokenizedValues[name] = value;
}
});
}
const descriptor = {id, defaultMessage};
const formattedMessage = formatMessage(
descriptor,
tokenizedValues || values,
);
const hasElements = elements && Object.keys(elements).length > 0;
let nodes;
if (hasElements) {
// Split the message into parts so the React Element values captured
// above can be inserted back into the rendered message. This
// approach allows messages to render with React Elements while
// keeping React's virtual diffing working properly.
nodes = formattedMessage.
split(tokenDelimiter).
filter((part) => Boolean(part)).
map((part) => elements[part] || part);
} else {
nodes = [formattedMessage];
}
return createElement(Text, otherProps, ...nodes);
};
FormattedText.defaultProps = {
defaultMessage: '',
};
export default FormattedText;

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {ActivityIndicator, StyleSheet, View, ViewStyle} from 'react-native';
type LoadingProps = {
color?: string,
size?: 'small' | 'large',
style?: ViewStyle,
}
const Loading = ({size = 'large', color = 'grey', style}: LoadingProps) => {
return (
<View style={styles.container}>
<ActivityIndicator
style={[styles.loading, style]}
animating={true}
size={size}
color={color}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loading: {
marginLeft: 3,
},
});
export default Loading;

View file

@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {storiesOf} from '@storybook/react-native';
import {withKnobs, select} from '@storybook/addon-knobs';
import Loading from './index';
storiesOf('Loading Icon Story', module).
addDecorator(withKnobs).
add('loading icon', () => (
<Loading
size={select('size', {Large: 'large', Small: 'small'}, 'large')}
color={select('color', {Red: 'red', Blue: 'blue', Yellow: 'yellow', Black: 'black'}, 'red')}
/>
));

18
app/constants/files.ts Normal file
View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const Files: Record<string, string[]> = {
AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'],
CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'],
IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif'],
PATCH_TYPES: ['patch'],
PDF_TYPES: ['pdf'],
PRESENTATION_TYPES: ['ppt', 'pptx'],
SPREADSHEET_TYPES: ['xlsx', 'csv'],
TEXT_TYPES: ['txt', 'rtf'],
VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'],
WORD_TYPES: ['doc', 'docx'],
ZIP_TYPES: ['zip'],
};
export default Files;

72
app/constants/general.ts Normal file
View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export default {
CONFIG_CHANGED: 'config_changed',
SERVER_VERSION_CHANGED: 'server_version_changed',
PAGE_SIZE_DEFAULT: 60,
PAGE_SIZE_MAXIMUM: 200,
LOGS_PAGE_SIZE_DEFAULT: 10000,
PROFILE_CHUNK_SIZE: 100,
CHANNELS_CHUNK_SIZE: 50,
TEAMS_CHUNK_SIZE: 50,
SEARCH_TIMEOUT_MILLISECONDS: 100,
STATUS_INTERVAL: 60000,
AUTOCOMPLETE_LIMIT_DEFAULT: 25,
AUTOCOMPLETE_SPLIT_CHARACTERS: ['.', '-', '_'],
MENTION: 'mention',
OUT_OF_OFFICE: 'ooo',
OFFLINE: 'offline',
AWAY: 'away',
ONLINE: 'online',
DND: 'dnd',
PERMISSIONS_ALL: 'all',
PERMISSIONS_CHANNEL_ADMIN: 'channel_admin',
PERMISSIONS_TEAM_ADMIN: 'team_admin',
PERMISSIONS_SYSTEM_ADMIN: 'system_admin',
TEAM_GUEST_ROLE: 'team_guest',
TEAM_USER_ROLE: 'team_user',
TEAM_ADMIN_ROLE: 'team_admin',
CHANNEL_GUEST_ROLE: 'channel_guest',
CHANNEL_USER_ROLE: 'channel_user',
CHANNEL_ADMIN_ROLE: 'channel_admin',
SYSTEM_GUEST_ROLE: 'system_guest',
SYSTEM_USER_ROLE: 'system_user',
SYSTEM_ADMIN_ROLE: 'system_admin',
SYSTEM_USER_ACCESS_TOKEN_ROLE: 'system_user_access_token',
SYSTEM_POST_ALL_ROLE: 'system_post_all',
SYSTEM_POST_ALL_PUBLIC_ROLE: 'system_post_all_public',
ALLOW_EDIT_POST_ALWAYS: 'always',
ALLOW_EDIT_POST_NEVER: 'never',
ALLOW_EDIT_POST_TIME_LIMIT: 'time_limit',
DEFAULT_POST_EDIT_TIME_LIMIT: 300,
RESTRICT_DIRECT_MESSAGE_ANY: 'any',
RESTRICT_DIRECT_MESSAGE_TEAM: 'team',
SWITCH_TO_DEFAULT_CHANNEL: 'switch_to_default_channel',
REMOVED_FROM_CHANNEL: 'removed_from_channel',
DEFAULT_CHANNEL: 'town-square',
DM_CHANNEL: 'D',
OPEN_CHANNEL: 'O',
PRIVATE_CHANNEL: 'P',
GM_CHANNEL: 'G',
PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn',
PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn',
STORE_REHYDRATION_COMPLETE: 'store_hydration_complete',
OFFLINE_STORE_RESET: 'offline_store_reset',
OFFLINE_STORE_PURGE: 'offline_store_purge',
TEAMMATE_NAME_DISPLAY: {
SHOW_USERNAME: 'username',
SHOW_NICKNAME_FULLNAME: 'nickname_full_name',
SHOW_FULLNAME: 'full_name',
},
SPECIAL_MENTIONS: ['all', 'channel', 'here'],
MAX_USERS_IN_GM: 8,
MIN_USERS_IN_GM: 3,
MAX_GROUP_CHANNELS_FOR_PROFILES: 50,
DEFAULT_LOCALE: 'en',
DEFAULT_AUTOLINKED_URL_SCHEMES: ['http', 'https', 'ftp', 'mailto', 'tel', 'mattermost'],
DISABLED: 'disabled',
DEFAULT_ON: 'default_on',
DEFAULT_OFF: 'default_off',
REHYDRATED: 'app/REHYDRATED',
};

View file

@ -2,9 +2,11 @@
// See LICENSE.txt for license information.
import Attachment from './attachment';
import SSO from './sso';
import Database from './database';
import DeepLink from './deep_linking';
import Device from './device';
import Files from './files';
import General from './general';
import List from './list';
import Navigation from './navigation';
import Preferences from './preferences';
@ -14,9 +16,11 @@ import WebsocketEvents from './websocket';
export {
Attachment,
SSO,
Database,
DeepLink,
Device,
Files,
General,
List,
Navigation,
Preferences,

View file

@ -2,14 +2,23 @@
// See LICENSE.txt for license information.
export const CHANNEL = 'Channel';
export const FORGOT_PASSWORD = 'ForgotPassword';
export const LOGIN = 'Login';
export const LOGIN_OPTIONS = 'LoginOptions';
export const MAIN_SIDEBAR = 'MainSidebar';
export const MFA = 'MFA';
export const SERVER = 'Server';
export const SETTINGS_SIDEBAR = 'SettingsSidebar';
export const SSO = 'SSO';
export const THREAD = 'Thread';
export default {
CHANNEL,
FORGOT_PASSWORD,
LOGIN,
LOGIN_OPTIONS,
MAIN_SIDEBAR,
MFA,
SERVER,
SETTINGS_SIDEBAR,
THREAD,

12
app/constants/sso.ts Normal file
View file

@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from '@utils/key_mirror';
export default keyMirror({
GITLAB: null,
GOOGLE: null,
OFFICE365: null,
OPENID: null,
SAML: null,
});

View file

@ -76,10 +76,6 @@ const ViewTypes = keyMirror({
REMOVE_LAST_CHANNEL_FOR_TEAM: null,
GITLAB: null,
OFFICE365: null,
SAML: null,
SET_INITIAL_POST_VISIBILITY: null,
RECEIVED_FOCUSED_POST: null,
LOADING_POSTS: null,

View file

@ -41,18 +41,23 @@ import {
} from '@database/models/server';
import {serverSchema} from '@database/schema/server';
import logger from '@nozbe/watermelondb/utils/common/logger';
import type {
ActiveServerDatabaseArgs,
import {
DatabaseConnectionArgs,
DatabaseInstance,
DatabaseInstances,
DefaultNewServerArgs,
GetDatabaseConnectionArgs,
Models,
RetrievedDatabase,
} from '@typings/database/database';
import {DatabaseType} from '@typings/database/enums';
import IGlobal from '@typings/database/global';
import IServers from '@typings/database/servers';
import urlParse from 'url-parse';
const {SERVERS} = MM_TABLES.DEFAULT;
const {SERVERS, GLOBAL} = MM_TABLES.DEFAULT;
const DEFAULT_DATABASE = 'default';
const RECENTLY_VIEWED_SERVERS = 'RECENTLY_VIEWED_SERVERS';
if (__DEV__) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -61,7 +66,6 @@ if (__DEV__) {
}
class DatabaseManager {
private activeDatabase: DatabaseInstance;
private defaultDatabase: DatabaseInstance;
private readonly defaultModels: Models;
private readonly iOSAppGroupDatabase: string | null;
@ -100,11 +104,58 @@ class DatabaseManager {
TermsOfService,
User,
];
this.iOSAppGroupDatabase = null;
this.androidFilesDirectory = null;
}
/**
* getDatabaseConnection: Given a server url (serverUrl) and a flag (setAsActiveDatabase), this method will attempt
* to retrieve an existing database connection previously created for that url. If not found, it will create a new connection and register it in the DEFAULT_DATABASE
* @param {GetDatabaseConnectionArgs} getDatabaseConnection
* @param {string} getDatabaseConnection.connectionName
* @param {string} getDatabaseConnection.serverUrl
* @param {boolean} getDatabaseConnection.setAsActiveDatabase
* @returns {Promise<DatabaseInstance>}
*/
getDatabaseConnection = async ({
serverUrl,
setAsActiveDatabase,
connectionName,
}: GetDatabaseConnectionArgs) => {
// We potentially already have this server registered; so we'll try to retrieve it if it is present under DEFAULT_DATABASE/GLOBAL entity
const existingServers = (await this.retrieveDatabaseInstances([
serverUrl,
])) as RetrievedDatabase[];
// Since we only passed one serverUrl, we'll expect only one value in the array
const serverDatabase = existingServers?.[0];
let connection: DatabaseInstance;
if (serverDatabase) {
// This serverUrl has previously been registered on the app
connection = serverDatabase.dbInstance;
} else {
// Or, it might be that the user has this server on the web-app but not mobile-app; so we'll need to create a new entry for this new serverUrl
const databaseName = connectionName ?? urlParse(serverUrl).hostname;
connection = await this.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: databaseName,
dbType: DatabaseType.SERVER,
serverUrl,
},
});
}
if (setAsActiveDatabase) {
await this.setActiveServerDatabase(serverUrl);
}
return connection;
};
/**
* createDatabaseConnection: Creates database connection and registers the new connection into the default database. However,
* if a database connection could not be created, it will return undefined.
@ -114,28 +165,17 @@ class DatabaseManager {
* @returns {Promise<DatabaseInstance>}
*/
createDatabaseConnection = async ({configs, shouldAddToDefaultDatabase = true}: DatabaseConnectionArgs): Promise<DatabaseInstance> => {
const {
actionsEnabled = true,
dbName = 'default',
dbType = DatabaseType.DEFAULT,
serverUrl = undefined,
} = configs;
const {actionsEnabled = true, dbName = DEFAULT_DATABASE, dbType = DatabaseType.DEFAULT, serverUrl = undefined} = configs;
try {
const databaseName = dbType === DatabaseType.DEFAULT ? 'default' : dbName;
const databaseName = dbType === DatabaseType.DEFAULT ? DEFAULT_DATABASE : dbName;
// const databaseFilePath = this.getDatabaseDirectory(databaseName);
// const databaseFilePath = this.getDatabaseFilePath(databaseName);
const migrations = dbType === DatabaseType.DEFAULT ? DefaultMigration : ServerMigration;
const modelClasses = dbType === DatabaseType.DEFAULT ? this.defaultModels : this.serverModels;
const schema = dbType === DatabaseType.DEFAULT ? defaultSchema : serverSchema;
const adapter = new LokiJSAdapter({
dbName: databaseName,
migrations,
schema,
useWebWorker: false,
useIncrementalIndexedDB: true,
});
const adapter = new LokiJSAdapter({dbName: databaseName, migrations, schema, useWebWorker: false, useIncrementalIndexedDB: true});
// Registers the new server connection into the DEFAULT database
if (serverUrl && shouldAddToDefaultDatabase) {
@ -157,22 +197,40 @@ class DatabaseManager {
/**
* setActiveServerDatabase: Set the new active server database. The serverUrl is used to ensure that we do not duplicate entries in the default database.
* This method should be called when switching to another server.
* @param {string} displayName
* @param {string} serverUrl
* @returns {Promise<void>}
*/
setActiveServerDatabase = async ({displayName, serverUrl}: ActiveServerDatabaseArgs) => {
const isServerPresent = await this.isServerPresent(serverUrl);
setActiveServerDatabase = async (serverUrl: string) => {
const defaultDatabase = await this.getDefaultDatabase();
this.activeDatabase = await this.createDatabaseConnection({
configs: {
actionsEnabled: true,
dbName: displayName,
dbType: DatabaseType.SERVER,
serverUrl,
},
shouldAddToDefaultDatabase: Boolean(!isServerPresent),
if (defaultDatabase) {
// retrieve recentlyViewedServers from Global entity
const recentlyViewedServers = (await defaultDatabase.collections.get(GLOBAL).query(Q.where('name', RECENTLY_VIEWED_SERVERS)).fetch()) as IGlobal[];
if (recentlyViewedServers.length) {
// We have previously written something for this flag
const flagRecord = recentlyViewedServers[0];
const recentList = Array.from(flagRecord.value);
recentList.unshift(serverUrl);
// so as to avoid duplicate in this array
const sanitizedList = Array.from(new Set(recentList));
await defaultDatabase.action(async () => {
await flagRecord.update((record) => {
record.value = sanitizedList;
});
});
} else {
// The flag hasn't been set; so we create the record
await defaultDatabase.action(async () => {
await defaultDatabase.collections.get(GLOBAL).create((record: IGlobal) => {
record.name = RECENTLY_VIEWED_SERVERS;
record.value = [serverUrl];
});
});
}
}
};
/**
@ -181,20 +239,42 @@ class DatabaseManager {
* @returns {Promise<boolean>}
*/
isServerPresent = async (serverUrl: string) => {
const allServers = await this.getAllServers();
const existingServer = allServers?.filter((server) => {
return server.url === serverUrl;
});
return existingServer && existingServer.length > 0;
const allServers = await this.getAllServers([serverUrl]);
return allServers?.length > 0;
};
/**
* getActiveServerUrl: Use this getter method to retrieve the active server URL.
* @returns {string}
*/
getActiveServerUrl = async (): Promise<string|undefined> => {
const defaultDatabase = await this.getDefaultDatabase();
if (defaultDatabase) {
const serverRecords = await defaultDatabase.collections.get(GLOBAL).query(Q.where('name', RECENTLY_VIEWED_SERVERS)).fetch() as IGlobal[];
if (serverRecords.length) {
const recentServers = serverRecords[0].value as string[];
return recentServers[0];
}
return undefined;
}
return undefined;
};
/**
* getActiveServerDatabase: The DatabaseManager should be the only one setting the active database. Hence, we have made the activeDatabase property private.
* Use this getter method to retrieve the active database if it has been set in your code.
* @returns {DatabaseInstance}
* @returns {Promise<DatabaseInstance | undefined>}
*/
getActiveServerDatabase = (): DatabaseInstance => {
return this.activeDatabase;
getActiveServerDatabase = async (): Promise<DatabaseInstance> => {
const serverUrl = await this.getActiveServerUrl();
if (serverUrl) {
const serverDatabase = await this.getDatabaseConnection({serverUrl, setAsActiveDatabase: false});
return serverDatabase;
}
return undefined;
};
/**
@ -213,21 +293,15 @@ class DatabaseManager {
* and return them to the caller.
*
* @param {string[]} serverUrls
* @returns {Promise<DatabaseInstances[] | null>}
* @returns {Promise<RetrievedDatabase[] | null>}
*/
retrieveDatabaseInstances = async (serverUrls?: string[]): Promise<DatabaseInstances[] | null> => {
if (serverUrls?.length) {
retrieveDatabaseInstances = async (serverUrls: string[]): Promise<DatabaseInstances[] | null> => {
// Retrieve all server records from the default db
const allServers = await this.getAllServers();
// Filter only those servers that are present in the serverUrls array
const servers = allServers!.filter((server: IServers) => {
return serverUrls.includes(server.url);
});
const allServers = await this.getAllServers(serverUrls);
// Creates server database instances
if (servers.length) {
const databasePromises = servers.map(async (server: IServers) => {
if (allServers.length) {
const databasePromises = allServers.map(async (server: IServers) => {
const {displayName, url} = server;
// Since we are retrieving existing URL ( and so database connections ) from the 'DEFAULT' database, shouldAddToDefaultDatabase is set to false
@ -240,16 +314,12 @@ class DatabaseManager {
},
shouldAddToDefaultDatabase: false,
});
return {url, dbInstance};
return {url, dbInstance, displayName};
});
const databaseInstances = await Promise.all(databasePromises);
return databaseInstances;
}
return null;
}
return null;
};
/**
@ -260,26 +330,40 @@ class DatabaseManager {
*/
deleteDatabase = async (serverUrl: string): Promise<boolean> => {
try {
const defaultDB = await this.getDefaultDatabase();
const defaultDatabase = await this.getDefaultDatabase();
let server: IServers;
if (defaultDB) {
const serversRecords = (await defaultDB.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch()) as IServers[];
let result = true;
if (defaultDatabase) {
const serversRecords = (await defaultDatabase.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch()) as IServers[];
server = serversRecords?.[0] ?? undefined;
const globalRecords = await defaultDatabase.collections.get(GLOBAL).query(Q.where('name', RECENTLY_VIEWED_SERVERS)).fetch() as IGlobal[];
const global = globalRecords?.[0] ?? undefined;
if (server) {
// Perform a delete operation for this server record on the 'servers' table in default database
await defaultDB.action(async () => {
await defaultDatabase.action(async () => {
await server.destroyPermanently();
});
return true;
result = result && true;
}
return false;
if (global) {
// filter out the deleted serverURL
const urls = global.value as string[];
const filtered = urls.filter((url) => url !== serverUrl);
await defaultDatabase.action(async () => {
await global.update((record) => {
record.value = filtered;
});
});
result = result && true;
}
return result;
}
return false;
} catch (e) {
// console.log('An error occurred while trying to delete database with name ', databaseName);
return false;
}
};
@ -288,11 +372,17 @@ class DatabaseManager {
* getAllServers : Retrieves all the servers registered in the default database
* @returns {Promise<undefined | Servers[]>}
*/
private getAllServers = async () => {
private getAllServers = async (serverUrls: string[]) => {
// Retrieve all server records from the default db
const defaultDatabase = await this.getDefaultDatabase();
const allServers = defaultDatabase && ((await defaultDatabase.collections.get(MM_TABLES.DEFAULT.SERVERS).query().fetch()) as IServers[]);
if (defaultDatabase) {
const allServers = (await defaultDatabase.collections.
get(SERVERS).
query(Q.where('url', Q.oneOf(serverUrls))).
fetch()) as IServers[];
return allServers;
}
return [];
};
/**
@ -301,7 +391,7 @@ class DatabaseManager {
*/
private setDefaultDatabase = async (): Promise<DatabaseInstance> => {
this.defaultDatabase = await this.createDatabaseConnection({
configs: {dbName: 'default'},
configs: {dbName: DEFAULT_DATABASE},
shouldAddToDefaultDatabase: false,
});
@ -322,7 +412,7 @@ class DatabaseManager {
if (defaultDatabase && !isServerPresent) {
await defaultDatabase.action(async () => {
const serversCollection = defaultDatabase.collections.get('servers');
const serversCollection = defaultDatabase.collections.get(SERVERS);
await serversCollection.create((server: IServers) => {
server.dbPath = databaseFilePath;
server.displayName = displayName;
@ -333,9 +423,10 @@ class DatabaseManager {
});
}
} catch (e) {
// console.log({catchError: e});
// eslint-disable-next-line no-console
console.log('addServerToDefaultDatabase ERROR:', e);
}
};
}
export default new DatabaseManager();
export default DatabaseManager;

View file

@ -43,23 +43,27 @@ import {
User,
} from '@database/models/server';
import {serverSchema} from '@database/schema/server';
import type {
ActiveServerDatabaseArgs,
import {
DatabaseConnectionArgs,
DatabaseInstance,
DatabaseInstances,
DefaultNewServerArgs,
GetDatabaseConnectionArgs,
MigrationEvents,
Models,
RetrievedDatabase,
} from '@typings/database/database';
import {DatabaseType} from '@typings/database/enums';
import IServers from '@typings/database/servers';
import IGlobal from '@typings/database/global';
import {deleteIOSDatabase, getIOSAppGroupDetails} from '@utils/mattermost_managed';
import urlParse from 'url-parse';
const {SERVERS} = MM_TABLES.DEFAULT;
const {SERVERS, GLOBAL} = MM_TABLES.DEFAULT;
const DEFAULT_DATABASE = 'default';
const RECENTLY_VIEWED_SERVERS = 'RECENTLY_VIEWED_SERVERS';
class DatabaseManager {
private activeDatabase: DatabaseInstance;
private defaultDatabase: DatabaseInstance;
private readonly defaultModels: Models;
private readonly iOSAppGroupDatabase: string | null;
@ -103,6 +107,50 @@ class DatabaseManager {
this.androidFilesDirectory = Platform.OS === 'android' ? FileSystem.documentDirectory : null;
}
/**
* getDatabaseConnection: Given a server url (serverUrl) and a flag (setAsActiveDatabase), this method will attempt
* to retrieve an existing database connection previously created for that url.
* If not found, it will create a new connection and register it in the DEFAULT_DATABASE. In this case, it will also
* use the provided connectionName as display_name for this server
* @param {GetDatabaseConnectionArgs} getDatabaseConnection
* @param {string} getDatabaseConnection.connectionName
* @param {string} getDatabaseConnection.serverUrl
* @param {boolean} getDatabaseConnection.setAsActiveDatabase
* @returns {Promise<DatabaseInstance>}
*/
getDatabaseConnection = async ({serverUrl, setAsActiveDatabase, connectionName}: GetDatabaseConnectionArgs) => {
// We potentially already have this server registered; so we'll try to retrieve it if it is present under DEFAULT_DATABASE/GLOBAL entity
const existingServers = await this.retrieveDatabaseInstances([serverUrl]) as RetrievedDatabase[];
// Since we only passed one serverUrl, we'll expect only one value in the array
const serverDatabase = existingServers?.[0];
let connection: DatabaseInstance;
if (serverDatabase) {
// This serverUrl has previously been registered on the app
connection = serverDatabase.dbInstance;
} else {
// Or, it might be that the user has this server on the web-app but not mobile-app; so we'll need to create a new entry for this new serverUrl
const databaseName = connectionName ?? urlParse(serverUrl).hostname;
connection = await this.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: databaseName,
dbType: DatabaseType.SERVER,
serverUrl,
},
});
}
if (setAsActiveDatabase) {
await this.setActiveServerDatabase(serverUrl);
}
return connection;
};
/**
* createDatabaseConnection: Creates database connection and registers the new connection into the default database. However,
* if a database connection could not be created, it will return undefined.
@ -112,16 +160,12 @@ class DatabaseManager {
* @returns {Promise<DatabaseInstance>}
*/
createDatabaseConnection = async ({configs, shouldAddToDefaultDatabase = true}: DatabaseConnectionArgs): Promise<DatabaseInstance> => {
const {
actionsEnabled = true,
dbName = 'default',
dbType = DatabaseType.DEFAULT,
serverUrl = undefined,
} = configs;
const {actionsEnabled = true, dbName = DEFAULT_DATABASE, dbType = DatabaseType.DEFAULT, serverUrl = undefined} = configs;
try {
const databaseName = dbType === DatabaseType.DEFAULT ? 'default' : dbName;
const databaseFilePath = this.getDatabaseDirectory(databaseName);
const databaseName = dbType === DatabaseType.DEFAULT ? DEFAULT_DATABASE : dbName;
const databaseFilePath = this.getDatabaseFilePath(databaseName);
const migrations = dbType === DatabaseType.DEFAULT ? DefaultMigration : ServerMigration;
const modelClasses = dbType === DatabaseType.DEFAULT ? this.defaultModels : this.serverModels;
const schema = dbType === DatabaseType.DEFAULT ? defaultSchema : serverSchema;
@ -153,46 +197,73 @@ class DatabaseManager {
/**
* setActiveServerDatabase: Set the new active server database. The serverUrl is used to ensure that we do not duplicate entries in the default database.
* This method should be called when switching to another server.
* @param {string} displayName
* @param {string} serverUrl
* @returns {Promise<void>}
*/
setActiveServerDatabase = async ({displayName, serverUrl}: ActiveServerDatabaseArgs) => {
const isServerPresent = await this.isServerPresent(serverUrl);
setActiveServerDatabase = async (serverUrl: string) => {
const defaultDatabase = await this.getDefaultDatabase();
if (defaultDatabase) {
// retrieve recentlyViewedServers from Global entity
const recentlyViewedServers = await defaultDatabase.collections.get(GLOBAL).query(Q.where('name', RECENTLY_VIEWED_SERVERS)).fetch() as IGlobal[];
this.activeDatabase = await this.createDatabaseConnection({
configs: {
actionsEnabled: true,
dbName: displayName,
dbType: DatabaseType.SERVER,
serverUrl,
},
shouldAddToDefaultDatabase: Boolean(!isServerPresent),
if (recentlyViewedServers.length) {
// We have previously written something for this flag
const flagRecord = recentlyViewedServers[0];
const recentList = Array.from(flagRecord.value);
recentList.unshift(serverUrl);
// so as to avoid duplicate in this array
const sanitizedList = Array.from(new Set(recentList));
await defaultDatabase.action(async () => {
await flagRecord.update((record) => {
record.value = sanitizedList;
});
});
} else {
// The flag hasn't been set; so we create the record
await defaultDatabase.action(async () => {
await defaultDatabase.collections.get(GLOBAL).create((record: IGlobal) => {
record.name = RECENTLY_VIEWED_SERVERS;
record.value = [serverUrl];
});
});
}
}
};
/**
* isServerPresent : Confirms if the current serverUrl does not already exist in the database
* @param {String} serverUrl
* @returns {Promise<boolean>}
* getActiveServerUrl: Use this getter method to retrieve the active server URL.
* @returns {string | undefined}
*/
private isServerPresent = async (serverUrl: string) => {
const allServers = await this.getAllServers();
getActiveServerUrl = async (): Promise<string | undefined> => {
const defaultDatabase = await this.getDefaultDatabase();
const existingServer = allServers?.filter((server) => {
return server.url === serverUrl;
});
if (defaultDatabase) {
const serverRecords = await defaultDatabase.collections.get(GLOBAL).query(Q.where('name', RECENTLY_VIEWED_SERVERS)).fetch() as IGlobal[];
return existingServer && existingServer?.length > 0;
if (serverRecords.length) {
const recentServers = serverRecords[0].value as string[];
return recentServers[0];
}
return undefined;
}
return undefined;
};
/**
* getActiveServerDatabase: The DatabaseManager should be the only one setting the active database. Hence, we have made the activeDatabase property private.
* Use this getter method to retrieve the active database if it has been set in your code.
* @returns {DatabaseInstance}
* @returns {Promise<MostRecentConnection | undefined>}
*/
getActiveServerDatabase = (): DatabaseInstance => {
return this.activeDatabase;
getActiveServerDatabase = async (): Promise<DatabaseInstance> => {
const serverUrl = await this.getActiveServerUrl();
if (serverUrl) {
const serverDatabase = await this.getDatabaseConnection({serverUrl, setAsActiveDatabase: false});
return serverDatabase;
}
return undefined;
};
/**
@ -211,21 +282,15 @@ class DatabaseManager {
* and return them to the caller.
*
* @param {string[]} serverUrls
* @returns {Promise<{url: string, dbInstance: DatabaseInstance}[] | null>}
* @returns {Promise<RetrievedDatabase[] | null>}
*/
retrieveDatabaseInstances = async (serverUrls?: string[]): Promise<DatabaseInstances[] | null> => {
if (serverUrls?.length) {
retrieveDatabaseInstances = async (serverUrls: string[]): Promise<DatabaseInstances[] | null> => {
// Retrieve all server records from the default db
const allServers = await this.getAllServers();
// Filter only those servers that are present in the serverUrls array
const servers = allServers!.filter((server: IServers) => {
return serverUrls.includes(server.url);
});
const allServers = await this.getAllServers(serverUrls);
// Creates server database instances
if (servers.length) {
const databasePromises = await servers.map(async (server: IServers) => {
if (allServers.length) {
const databasePromises = await allServers.map(async (server: IServers) => {
const {displayName, url} = server;
// Since we are retrieving existing URL ( and so database connections ) from the 'DEFAULT' database, shouldAddToDefaultDatabase is set to false
@ -239,15 +304,12 @@ class DatabaseManager {
shouldAddToDefaultDatabase: false,
});
return {url, dbInstance};
return {dbInstance, displayName, url};
});
const databaseInstances = await Promise.all(databasePromises);
return databaseInstances;
}
return null;
}
return null;
};
/**
@ -258,24 +320,26 @@ class DatabaseManager {
*/
deleteDatabase = async (serverUrl: string): Promise<boolean> => {
try {
const defaultDB = await this.getDefaultDatabase();
const defaultDatabase = await this.getDefaultDatabase();
let server: IServers;
let result = true;
if (defaultDB) {
const serversRecords = (await defaultDB.collections.
get(SERVERS).
query(Q.where('url', serverUrl)).
fetch()) as IServers[];
if (defaultDatabase) {
// NOTE: We are deleting this 'database' entry in the SERVERS entity on the DEFAULT database; for that we retrieve its record first.
const serversRecords = (await defaultDatabase.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch()) as IServers[];
server = serversRecords?.[0] ?? undefined;
const globalRecords = await defaultDatabase.collections.get(GLOBAL).query(Q.where('name', RECENTLY_VIEWED_SERVERS)).fetch() as IGlobal[];
const global = globalRecords?.[0] ?? undefined;
if (server) {
const databaseName = server.displayName;
// Perform a delete operation for this server record on the 'servers' table in default database
await defaultDB.action(async () => {
await defaultDatabase.action(async () => {
await server.destroyPermanently();
});
const databaseName = server.displayName;
if (Platform.OS === 'ios') {
// On iOS, we'll delete the *.db file under the shared app-group/databases folder
deleteIOSDatabase({databaseName});
@ -290,13 +354,24 @@ class DatabaseManager {
await FileSystem.deleteAsync(databaseFile);
await FileSystem.deleteAsync(databaseJournal);
return true;
result = result && true;
}
return false;
if (global) {
// filter out the deleted serverURL
const urls = global.value as string[];
const filtered = urls.filter((url) => url !== serverUrl);
await defaultDatabase.action(async () => {
await global.update((record) => {
record.value = filtered;
});
});
result = result && true;
}
return result;
}
return false;
} catch (e) {
// console.log('An error occurred while trying to delete database with name ', databaseName);
return false;
}
};
@ -319,20 +394,34 @@ class DatabaseManager {
await FileSystem.deleteAsync(androidFilesDir);
return true;
} catch (e) {
// console.log('An error occurred while trying to delete the databases directory);
return false;
}
};
/**
* getAllServers : Retrieves all the servers registered in the default database
* @returns {Promise<undefined | Servers[]>}
* isServerPresent : Confirms if the current serverUrl does not already exist in the database
* @param {String} serverUrl
* @returns {Promise<boolean>}
*/
private getAllServers = async () => {
private isServerPresent = async (serverUrl: string) => {
const allServers = await this.getAllServers([serverUrl]);
return allServers?.length > 0;
};
/**
* getAllServers : Retrieves all the servers registered in the default database
* @returns {Promise<Servers[]>}
*/
private getAllServers = async (serverUrls: string[]) => {
// Retrieve all server records from the default db
const defaultDatabase = await this.getDefaultDatabase();
const allServers = defaultDatabase && ((await defaultDatabase.collections.get(MM_TABLES.DEFAULT.SERVERS).query().fetch()) as IServers[]);
if (defaultDatabase) {
const allServers = (await defaultDatabase.collections.get(SERVERS).query(Q.where('url', Q.oneOf(serverUrls))).fetch() as IServers[]);
return allServers;
}
return [];
};
/**
@ -341,7 +430,7 @@ class DatabaseManager {
*/
private setDefaultDatabase = async (): Promise<DatabaseInstance> => {
this.defaultDatabase = await this.createDatabaseConnection({
configs: {dbName: 'default'},
configs: {dbName: DEFAULT_DATABASE},
shouldAddToDefaultDatabase: false,
});
@ -362,7 +451,7 @@ class DatabaseManager {
if (defaultDatabase && !isServerPresent) {
await defaultDatabase.action(async () => {
const serversCollection = defaultDatabase.collections.get('servers');
const serversCollection = defaultDatabase.collections.get(SERVERS);
await serversCollection.create((server: IServers) => {
server.dbPath = databaseFilePath;
server.displayName = displayName;
@ -408,7 +497,7 @@ class DatabaseManager {
};
/**
* getDatabaseDirectory: Using the database name, this method will return the database directory for each platform.
* getDatabaseFilePath: Using the database name, this method will return the database file path for each platform.
* On iOS, it will point towards the AppGroup shared directory while on Android, it will point towards the Files Directory.
* Please note that in each case, the *.db files will be created/grouped under a 'databases' sub-folder.
* iOS Simulator : appGroup => /Users/{username}/Library/Developer/CoreSimulator/Devices/DA6F1C73/data/Containers/Shared/AppGroup/ACA65327/databases"}
@ -417,7 +506,7 @@ class DatabaseManager {
* @param {string} dbName
* @returns {string}
*/
private getDatabaseDirectory = (dbName: string): string => {
private getDatabaseFilePath = (dbName: string): string => {
return Platform.OS === 'ios' ? `${this.iOSAppGroupDatabase}/${dbName}.db` : `${FileSystem.documentDirectory}${dbName}.db`;
};
}
@ -428,4 +517,4 @@ if (!__DEV__) {
logger.silence();
}
export default new DatabaseManager();
export default DatabaseManager;

View file

@ -1,18 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import {DatabaseInstance} from '@typings/database/database';
import {DatabaseType} from '@typings/database/enums';
import IGlobal from '@typings/database/global';
import IServers from '@typings/database/servers';
import DatabaseManager from '@database/manager';
jest.mock('@database/manager');
const {SERVERS} = MM_TABLES.DEFAULT;
const {GLOBAL, SERVERS} = MM_TABLES.DEFAULT;
const RECENTLY_VIEWED_SERVERS = 'RECENTLY_VIEWED_SERVERS';
// NOTE : On the mock Database Manager, we cannot test for :
// 1. Android/iOS file path
@ -21,12 +22,43 @@ const {SERVERS} = MM_TABLES.DEFAULT;
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('*** Database Manager tests ***', () => {
let databaseManagerClient: DatabaseManager | null;
beforeEach(() => {
databaseManagerClient = new DatabaseManager();
});
afterEach(() => {
databaseManagerClient = null;
});
const createTwoConnections = async () => {
await databaseManagerClient!.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: 'connection1',
dbType: DatabaseType.SERVER,
serverUrl: 'https://appv1.mattermost.com',
},
});
await databaseManagerClient!.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: 'connection2',
dbType: DatabaseType.SERVER,
serverUrl: 'https://appv2.mattermost.com',
},
});
};
it('=> should return a default database', async () => {
expect.assertions(2);
const spyOnAddServerToDefaultDatabase = jest.spyOn(DatabaseManager as any, 'addServerToDefaultDatabase');
const spyOnAddServerToDefaultDatabase = jest.spyOn(databaseManagerClient as any, 'addServerToDefaultDatabase');
const defaultDB = await DatabaseManager.getDefaultDatabase();
const defaultDB = await databaseManagerClient!.getDefaultDatabase();
expect(defaultDB).toBeInstanceOf(Database);
expect(spyOnAddServerToDefaultDatabase).not.toHaveBeenCalledTimes(1);
@ -35,9 +67,9 @@ describe('*** Database Manager tests ***', () => {
it('=> should create a new server connection', async () => {
expect.assertions(2);
const spyOnAddServerToDefaultDatabase = jest.spyOn(DatabaseManager as any, 'addServerToDefaultDatabase');
const spyOnAddServerToDefaultDatabase = jest.spyOn(databaseManagerClient as any, 'addServerToDefaultDatabase');
const connection1 = await DatabaseManager.createDatabaseConnection({
const connection1 = await databaseManagerClient!.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
@ -52,53 +84,59 @@ describe('*** Database Manager tests ***', () => {
});
it('=> should switch between active server connections', async () => {
expect.assertions(5);
let activeServer: DatabaseInstance;
expect.assertions(6);
let adapter;
activeServer = await DatabaseManager.getActiveServerDatabase();
const activeServerA = await databaseManagerClient!.getActiveServerDatabase();
// as we haven't set an active server yet, we should be getting undefined in the activeServer variable
expect(activeServer).toBeUndefined();
expect(activeServerA).toBeUndefined();
const setActiveServer = async ({displayName, serverUrl}:{displayName: string, serverUrl: string}) => {
const setActiveServer = async (serverUrl: string) => {
// now we set the active database
await DatabaseManager.setActiveServerDatabase({displayName, serverUrl});
await databaseManagerClient!.setActiveServerDatabase(serverUrl);
};
await setActiveServer({displayName: 'community mattermost', serverUrl: 'https://appv1.mattermost.com'});
await setActiveServer('https://appv1.mattermost.com');
// let's verify if we now have a value for activeServer
activeServer = await DatabaseManager.getActiveServerDatabase();
expect(activeServer).toBeDefined();
const activeServerB = await databaseManagerClient!.getActiveServerDatabase();
expect(activeServerB).toBeDefined();
adapter = activeServer!.adapter as any;
adapter = activeServerB!.adapter as any;
const currentDBName = adapter.underlyingAdapter._dbName;
expect(currentDBName).toStrictEqual('community mattermost');
expect(currentDBName).toStrictEqual('appv1.mattermost.com');
// spice things up; we'll set a new server and verify if the value of activeServer changes
await setActiveServer({displayName: 'appv2', serverUrl: 'https://appv2.mattermost.com'});
activeServer = await DatabaseManager.getActiveServerDatabase();
expect(activeServer).toBeDefined();
await setActiveServer('https://appv2.mattermost.com');
const activeServerC = await databaseManagerClient!.getActiveServerDatabase();
expect(activeServerC).toBeDefined();
adapter = activeServer!.adapter as any;
adapter = activeServerC!.adapter as any;
const newDBName = adapter.underlyingAdapter._dbName;
expect(newDBName).toStrictEqual('appv2');
expect(newDBName).toStrictEqual('appv2.mattermost.com');
const defaultDatabase = await databaseManagerClient!.getDefaultDatabase();
const records = await defaultDatabase!.collections.get(MM_TABLES.DEFAULT.GLOBAL).query(Q.where('name', 'RECENTLY_VIEWED_SERVERS')).fetch() as IGlobal[];
const recentlyViewedServers = records?.[0]?.value;
expect(recentlyViewedServers?.length).toBe(2);
});
it('=> should retrieve all database instances matching serverUrls parameter', async () => {
expect.assertions(3);
const spyOnCreateDatabaseConnection = jest.spyOn(DatabaseManager, 'createDatabaseConnection');
await createTwoConnections();
const dbInstances = await DatabaseManager.retrieveDatabaseInstances([
const spyOnCreateDatabaseConnection = jest.spyOn(databaseManagerClient!, 'createDatabaseConnection');
const dbInstances = await databaseManagerClient!.retrieveDatabaseInstances([
'https://xunity2.mattermost.com',
'https://appv2.mattermost.com',
'https://appv1.mattermost.com',
]);
expect(dbInstances).toBeTruthy();
const numDbInstances = dbInstances && dbInstances.length ? dbInstances.length : 0;
const numDbInstances = dbInstances?.length ?? 0;
// The Database Manager will call the 'createDatabaseConnection' method in consequence of the number of database connection present in dbInstances array
expect(spyOnCreateDatabaseConnection).toHaveBeenCalledTimes(numDbInstances);
@ -107,12 +145,23 @@ describe('*** Database Manager tests ***', () => {
expect(numDbInstances).toEqual(2);
});
it('=> should retrieve existing database instances matching serverUrl parameter', async () => {
expect.assertions(2);
await createTwoConnections();
const spyOnRetrieveDatabaseInstances = jest.spyOn(databaseManagerClient!, 'retrieveDatabaseInstances');
const connection = await databaseManagerClient!.getDatabaseConnection({serverUrl: 'https://appv1.mattermost.com', setAsActiveDatabase: false});
expect(spyOnRetrieveDatabaseInstances).toHaveBeenCalledTimes(1);
expect(connection).toBeDefined();
});
//todo: test the current active database together with the getDatabaseConnection method
it('=> should have records of Servers set in the servers table of the default database', async () => {
expect.assertions(3);
const defaultDB = await DatabaseManager.getDefaultDatabase();
const defaultDB = await databaseManagerClient!.getDefaultDatabase();
expect(defaultDB).toBeDefined();
await createTwoConnections();
const serversRecords = await defaultDB!.collections.get(SERVERS).query().fetch() as IServers[];
expect(serversRecords).toBeDefined();
@ -122,16 +171,29 @@ describe('*** Database Manager tests ***', () => {
it('=> should delete appv1 server from the servers table of Default database', async () => {
expect.assertions(3);
await createTwoConnections();
const defaultDatabase = await databaseManagerClient!.getDefaultDatabase();
await databaseManagerClient?.setActiveServerDatabase('https://appv1.mattermost.com');
await databaseManagerClient?.setActiveServerDatabase('https://appv2.mattermost.com');
const fetchGlobalRecords = async () => {
const initialGlobalRecords = await defaultDatabase!.collections.get(GLOBAL).query(Q.where('name', RECENTLY_VIEWED_SERVERS)).fetch() as IGlobal[];
return initialGlobalRecords?.[0].value as string[];
};
const recentServers = await fetchGlobalRecords();
expect(recentServers.length).toBe(2);
// Removing database for appv1 connection
const isAppV1Removed = await DatabaseManager.deleteDatabase('https://appv1.mattermost.com');
const isAppV1Removed = await databaseManagerClient!.deleteDatabase('https://appv1.mattermost.com');
expect(isAppV1Removed).toBe(true);
// Verifying in the database to confirm if its record was deleted
const defaultDB = await DatabaseManager.getDefaultDatabase();
const serversRecords = await defaultDB!.collections.get(SERVERS).query().fetch() as IServers[];
expect(serversRecords).toBeDefined();
expect(serversRecords.length).toEqual(1);
const updatedRecentServers = await fetchGlobalRecords();
expect(updatedRecentServers.length).toBe(1);
});
it('=> should enforce uniqueness of connections using serverUrl as key', async () => {
@ -139,7 +201,7 @@ describe('*** Database Manager tests ***', () => {
// We can't have more than one connection with the same server url
const serverUrl = 'https://appv3.mattermost.com';
await DatabaseManager.createDatabaseConnection({
await databaseManagerClient!.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
@ -149,7 +211,7 @@ describe('*** Database Manager tests ***', () => {
},
});
await DatabaseManager.createDatabaseConnection({
await databaseManagerClient!.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
@ -159,7 +221,7 @@ describe('*** Database Manager tests ***', () => {
},
});
const defaultDB = await DatabaseManager.getDefaultDatabase();
const defaultDB = await databaseManagerClient!.getDefaultDatabase();
const allServers = defaultDB && await defaultDB.collections.get(SERVERS).query().fetch() as IServers[];

View file

@ -6,9 +6,11 @@ import {Platform} from 'react-native';
import {DatabaseType} from '@typings/database/enums';
import {getIOSAppGroupDetails} from '@utils/mattermost_managed';
import DBManager from './index';
import DatabaseManager from './index';
export default async () => {
const databaseClient = new DatabaseManager();
// Test: It should return the iOS App-Group shared directory
const testAppGroupDirectory = () => {
if (Platform.OS === 'ios') {
@ -18,12 +20,12 @@ export default async () => {
// Test: It should return an instance of the default database
const testGetDefaultDatabase = () => {
DBManager.getDefaultDatabase();
databaseClient.getDefaultDatabase();
};
// Test: It should creates a new server connection
const testNewServerConnection = async () => {
await DBManager.createDatabaseConnection({
await databaseClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
@ -36,20 +38,17 @@ export default async () => {
// Test: It should return the current active server database
const testGetActiveServerConnection = () => {
// const activeServer = DBManager.getActiveServerDatabase();
// const activeServer = DatabaseManager.getActiveServerDatabase();
};
// Test: It should set the current active server database to the provided server url.
const testSetActiveServerConnection = async () => {
await DBManager.setActiveServerDatabase({
displayName: 'comm4',
serverUrl: 'https://comm4.mattermost.com',
});
await databaseClient.setActiveServerDatabase('https://comm4.mattermost.com');
};
// Test: It should return database instance(s) if there are valid server urls in the provided list.
const testRetrieveAllDatabaseConnections = async () => {
await DBManager.retrieveDatabaseInstances([
await databaseClient.retrieveDatabaseInstances([
'https://xunity2.mattermost.com',
'https://comm5.mattermost.com',
'https://comm4.mattermost.com',
@ -58,12 +57,12 @@ export default async () => {
// Test: It should delete the associated *.db file for this server url
const testDeleteSQLFile = async () => {
await DBManager.deleteDatabase('https://comm4.mattermost.com');
await databaseClient.deleteDatabase('https://comm4.mattermost.com');
};
// Test: It should wipe out the databases folder under the documents direction on Android and in the shared directory for the AppGroup on iOS
const testFactoryReset = async () => {
await DBManager.factoryReset(true);
await databaseClient.factoryReset(true);
};
// NOTE : Comment and test the below functions one at a time. It starts with creating a default database and ends with a factory reset.

View file

@ -22,5 +22,5 @@ export default class Global extends Model {
@field('name') name!: string;
/** value : The value part of the key-value combination */
@json('value', (rawJson) => rawJson) value!: string;
@json('value', (rawJson) => rawJson) value!: any;
}

View file

@ -80,7 +80,7 @@ export const isRecordRoleEqualToRaw = (record: Role, raw: RawRole) => {
};
export const isRecordSystemEqualToRaw = (record: System, raw: RawSystem) => {
return raw.id === record.id;
return raw.name === record.name;
};
export const isRecordTermsOfServiceEqualToRaw = (record: TermsOfService, raw: RawTermsOfService) => {

View file

@ -3,7 +3,7 @@
import DataOperatorException from '@database/exceptions/data_operator_exception';
import DatabaseManager from '@database/manager';
import {DataOperator} from '@database/operator';
import Operator from '@database/operator';
import {
isRecordAppEqualToRaw,
isRecordCustomEmojiEqualToRaw,
@ -24,22 +24,40 @@ import {
} from '@database/operator/prepareRecords/general';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {RawGlobal, RawRole, RawServers, RawTermsOfService} from '@typings/database/database';
import {IsolatedEntities} from '@typings/database/enums';
import {DatabaseType, IsolatedEntities} from '@typings/database/enums';
jest.mock('@database/manager');
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('*** DataOperator: Base Handlers tests ***', () => {
let databaseManagerClient: DatabaseManager;
let operatorClient: Operator;
beforeAll(async () => {
databaseManagerClient = new DatabaseManager();
const database = await databaseManagerClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: 'base_handler',
dbType: DatabaseType.SERVER,
serverUrl: 'baseHandler.test.com',
},
});
operatorClient = new Operator(database!);
});
it('=> HandleApp: should write to APP entity', async () => {
expect.assertions(3);
const defaultDB = await DatabaseManager.getDefaultDatabase();
expect(defaultDB).toBeTruthy();
const defaultDatabase = await databaseManagerClient.getDefaultDatabase();
expect(defaultDatabase).toBeTruthy();
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await DataOperator.handleIsolatedEntity({
await operatorClient.handleIsolatedEntity({
tableName: IsolatedEntities.APP,
values: [
{
@ -81,13 +99,13 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
it('=> HandleGlobal: should write to GLOBAL entity', async () => {
expect.assertions(2);
const defaultDB = await DatabaseManager.getDefaultDatabase();
expect(defaultDB).toBeTruthy();
const defaultDatabase = await databaseManagerClient.getDefaultDatabase();
expect(defaultDatabase).toBeTruthy();
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
const values: RawGlobal[] = [{name: 'global-1-name', value: 'global-1-value'}];
await DataOperator.handleIsolatedEntity({
await operatorClient.handleIsolatedEntity({
tableName: IsolatedEntities.GLOBAL,
values,
prepareRecordsOnly: false,
@ -106,10 +124,10 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
it('=> HandleServers: should write to SERVERS entity', async () => {
expect.assertions(2);
const defaultDB = await DatabaseManager.getDefaultDatabase();
expect(defaultDB).toBeTruthy();
const defaultDatabase = await databaseManagerClient.getDefaultDatabase();
expect(defaultDatabase).toBeTruthy();
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
const values: RawServers[] = [
{
@ -121,7 +139,7 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
},
];
await DataOperator.handleIsolatedEntity({
await operatorClient.handleIsolatedEntity({
tableName: IsolatedEntities.SERVERS,
values,
prepareRecordsOnly: false,
@ -150,7 +168,7 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
await createTestConnection({databaseName: 'base_handler', setActive: true});
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
const values: RawRole[] = [
{
@ -160,7 +178,7 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
},
];
await DataOperator.handleIsolatedEntity({
await operatorClient.handleIsolatedEntity({
tableName: IsolatedEntities.ROLE,
values,
prepareRecordsOnly: false,
@ -185,11 +203,11 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
it('=> HandleCustomEmojis: should write to CUSTOM_EMOJI entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'base_handler', setActive: true});
await DataOperator.handleIsolatedEntity({
await operatorClient.handleIsolatedEntity({
tableName: IsolatedEntities.CUSTOM_EMOJI,
values: [
{
@ -229,11 +247,11 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
await createTestConnection({databaseName: 'base_handler', setActive: true});
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
const values = [{id: 'system-id-1', name: 'system-1', value: 'system-1'}];
await DataOperator.handleIsolatedEntity({
await operatorClient.handleIsolatedEntity({
tableName: IsolatedEntities.SYSTEM,
values,
prepareRecordsOnly: false,
@ -241,7 +259,7 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({
findMatchingRecordBy: isRecordSystemEqualToRaw,
fieldName: 'id',
fieldName: 'name',
operator: prepareSystemRecord,
rawValues: values,
tableName: 'System',
@ -254,7 +272,7 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
await createTestConnection({databaseName: 'base_handler', setActive: true});
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
const values: RawTermsOfService[] = [
{
@ -266,7 +284,7 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
},
];
await DataOperator.handleIsolatedEntity({
await operatorClient.handleIsolatedEntity({
tableName: IsolatedEntities.TERMS_OF_SERVICE,
values,
prepareRecordsOnly: false,
@ -285,11 +303,11 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
it('=> No table name: should not call executeInDatabase if tableName is invalid', async () => {
expect.assertions(2);
const defaultDB = await DatabaseManager.getDefaultDatabase();
expect(defaultDB).toBeTruthy();
const defaultDatabase = await databaseManagerClient.getDefaultDatabase();
expect(defaultDatabase).toBeTruthy();
await expect(
DataOperator.handleIsolatedEntity({
operatorClient.handleIsolatedEntity({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
tableName: 'INVALID_TABLE_NAME',

View file

@ -30,7 +30,7 @@ import {
getRangeOfValues,
getRawRecordPairs,
getUniqueRawsBy,
hasSimilarUpdateAt,
getValidRecordsForUpdate,
retrieveRecords,
} from '@database/operator/utils/general';
import {
@ -50,7 +50,7 @@ export interface BaseHandlerMix {
activeDatabase: Database;
getActiveDatabase: () => DatabaseInstance;
setActiveDatabase: (database: Database) => void;
handleIsolatedEntity: ({tableName, values, prepareRecordsOnly}: HandleIsolatedEntityArgs) => boolean | Model[];
handleIsolatedEntity: ({tableName, values, prepareRecordsOnly}: HandleIsolatedEntityArgs) => Model[];
handleEntityRecords: ({findMatchingRecordBy, fieldName, operator, rawValues, tableName, prepareRecordsOnly}: HandleEntityRecordsArgs) => Promise<null | Model[]>;
processInputs: ({rawValues, tableName, findMatchingRecordBy, fieldName}: ProcessInputsArgs) => Promise<{ createRaws: RecordPair[]; updateRaws: RecordPair[] }>;
batchOperations: ({database, models}: BatchOperationsArgs) => Promise<void>;
@ -68,7 +68,7 @@ class BaseHandler {
*/
activeDatabase: DatabaseInstance;
constructor(serverDatabase?: Database) {
constructor(serverDatabase: Database) {
this.activeDatabase = serverDatabase;
}
@ -94,13 +94,14 @@ class BaseHandler {
* @param {boolean} isolatedEntityArgs.prepareRecordsOnly
* @param {RawValue} isolatedEntityArgs.values
* @throws DataOperatorException
* @returns {Model[] | boolean}
* @returns {Model[]}
*/
handleIsolatedEntity = async ({tableName, values, prepareRecordsOnly = true}: HandleIsolatedEntityArgs) => {
let findMatchingRecordBy;
let fieldName;
let operator;
let rawValues;
let records: Model[] = [];
if (!values.length) {
throw new DataOperatorException(
@ -146,9 +147,9 @@ class BaseHandler {
}
case IsolatedEntities.SYSTEM: {
findMatchingRecordBy = isRecordSystemEqualToRaw;
fieldName = 'id';
fieldName = 'name';
operator = prepareSystemRecord;
rawValues = getUniqueRawsBy({raws: values, key: 'id'});
rawValues = getUniqueRawsBy({raws: values, key: 'name'});
break;
}
case IsolatedEntities.TERMS_OF_SERVICE: {
@ -166,7 +167,7 @@ class BaseHandler {
}
if (fieldName && findMatchingRecordBy) {
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName,
findMatchingRecordBy,
operator,
@ -175,10 +176,10 @@ class BaseHandler {
tableName,
});
return prepareRecordsOnly && records?.length && records;
return records;
}
return false;
return records;
};
/**
@ -189,13 +190,14 @@ class BaseHandler {
* @param {(DataFactoryArgs) => Promise<Model>} handleEntityArgs.operator
* @param {RawValue[]} handleEntityArgs.rawValues
* @param {string} handleEntityArgs.tableName
* @returns {Promise<null | Model[]>}
* @returns {Promise<Model[]>}
*/
handleEntityRecords = async ({findMatchingRecordBy, fieldName, operator, rawValues, tableName, prepareRecordsOnly = true}: HandleEntityRecordsArgs) => {
if (!rawValues.length) {
return null;
throw new DataOperatorException(
`An empty "rawValues" array has been passed to the handleEntityRecords method for tableName ${tableName}`,
);
}
const {createRaws, updateRaws} = await this.processInputs({
rawValues,
tableName,
@ -205,7 +207,8 @@ class BaseHandler {
const database = await this.getDatabase(tableName);
const models = await this.prepareRecords({
let models: Model[] = [];
models = await this.prepareRecords({
database,
tableName,
createRaws,
@ -221,7 +224,7 @@ class BaseHandler {
await this.batchOperations({database, models});
}
return null;
return models;
};
/**
@ -263,23 +266,17 @@ class BaseHandler {
const existingRecord = existingRecords[findIndex];
// Some raw value has an update_at field. We'll proceed to update only if the update_at value is different from the record's value in database
const isUpdateAtSimilar = hasSimilarUpdateAt({
const updateRecords = getValidRecordsForUpdate({
tableName,
existingRecord,
newValue: newElement,
});
if (!isUpdateAtSimilar) {
return updateRaws.push({
record: existingRecord,
raw: newElement,
});
return updateRaws.push(updateRecords);
}
} else {
// This RawValue is not present in the database; hence, we need to create it
return createRaws.push({record: undefined, raw: newElement});
}
return null;
});
return {
@ -419,7 +416,8 @@ class BaseHandler {
* @returns {Promise<Database>}
*/
getDefaultDatabase = async () => {
const connection = await DatabaseManager.getDefaultDatabase();
const databaseManagerClient = new DatabaseManager();
const connection = await databaseManagerClient.getDefaultDatabase();
if (connection === undefined) {
throw new DatabaseConnectionException(
'An error occurred while retrieving the default database',
@ -440,16 +438,10 @@ class BaseHandler {
return this.activeDatabase;
}
// NOTE: here we are getting the active server directly as in a multi-server support system, the current
// active server connection will already be set on application init
const connection = await DatabaseManager.getActiveServerDatabase();
if (connection === undefined) {
throw new DatabaseConnectionException(
'An error occurred while retrieving the server database',
"This operator client didn't have its activeDatabase set",
'',
);
}
return connection;
};
}

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DataOperator} from '@database/operator';
import DatabaseManager from '@database/manager';
import Operator from '@database/operator';
import {
isRecordChannelEqualToRaw,
isRecordChannelInfoEqualToRaw,
@ -15,20 +16,39 @@ import {
prepareMyChannelSettingsRecord,
} from '@database/operator/prepareRecords/channel';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {DatabaseType} from '@typings/database/enums';
jest.mock('@database/manager');
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('*** Operator: Channel Handlers tests ***', () => {
let databaseManagerClient: DatabaseManager;
let operatorClient: Operator;
beforeAll(async () => {
databaseManagerClient = new DatabaseManager();
const database = await databaseManagerClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: 'base_handler',
dbType: DatabaseType.SERVER,
serverUrl: 'baseHandler.test.com',
},
});
operatorClient = new Operator(database!);
});
it('=> HandleChannel: should write to CHANNEL entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'channel_handler', setActive: true});
await DataOperator.handleChannel({
await operatorClient.handleChannel({
channels: [
{
id: 'kjlw9j1ttnxwig7tnqgebg7dtipno',
@ -89,11 +109,11 @@ describe('*** Operator: Channel Handlers tests ***', () => {
it('=> HandleMyChannelSettings: should write to MY_CHANNEL_SETTINGS entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'channel_handler', setActive: true});
await DataOperator.handleMyChannelSettings({
await operatorClient.handleMyChannelSettings({
settings: [
{
channel_id: 'c',
@ -138,11 +158,11 @@ describe('*** Operator: Channel Handlers tests ***', () => {
it('=> HandleChannelInfo: should write to CHANNEL_INFO entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'channel_handler', setActive: true});
await DataOperator.handleChannelInfo({
await operatorClient.handleChannelInfo({
channelInfos: [
{
channel_id: 'c',
@ -180,11 +200,11 @@ describe('*** Operator: Channel Handlers tests ***', () => {
it('=> HandleMyChannel: should write to MY_CHANNEL entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'channel_handler', setActive: true});
await DataOperator.handleMyChannel({
await operatorClient.handleMyChannel({
myChannels: [
{
channel_id: 'c',

View file

@ -48,9 +48,11 @@ const ChannelHandler = (superclass: any) => class extends superclass {
* @param {RawChannel[]} channelsArgs.channels
* @param {boolean} channelsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {Channel[]|boolean}
* @returns {Channel[]}
*/
handleChannel = async ({channels, prepareRecordsOnly = true}: HandleChannelArgs) => {
let records: Channel[] = [];
if (!channels.length) {
throw new DataOperatorException(
'An empty "channels" array has been passed to the handleChannel method',
@ -59,7 +61,7 @@ const ChannelHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: channels, key: 'id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'id',
findMatchingRecordBy: isRecordChannelEqualToRaw,
operator: prepareChannelRecord,
@ -68,7 +70,7 @@ const ChannelHandler = (superclass: any) => class extends superclass {
tableName: CHANNEL,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -77,9 +79,11 @@ const ChannelHandler = (superclass: any) => class extends superclass {
* @param {RawMyChannelSettings[]} settingsArgs.settings
* @param {boolean} settingsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {MyChannelSettings[]| boolean}
* @returns {MyChannelSettings[]}
*/
handleMyChannelSettings = async ({settings, prepareRecordsOnly = true}: HandleMyChannelSettingsArgs) => {
let records: MyChannelSettings[] = [];
if (!settings.length) {
throw new DataOperatorException(
'An empty "settings" array has been passed to the handleMyChannelSettings method',
@ -88,7 +92,7 @@ const ChannelHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: settings, key: 'channel_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'channel_id',
findMatchingRecordBy: isRecordMyChannelSettingsEqualToRaw,
operator: prepareMyChannelSettingsRecord,
@ -97,7 +101,7 @@ const ChannelHandler = (superclass: any) => class extends superclass {
tableName: MY_CHANNEL_SETTINGS,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -106,9 +110,11 @@ const ChannelHandler = (superclass: any) => class extends superclass {
* @param {RawChannelInfo[]} channelInfosArgs.channelInfos
* @param {boolean} channelInfosArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {ChannelInfo[]| boolean}
* @returns {ChannelInfo[]}
*/
handleChannelInfo = async ({channelInfos, prepareRecordsOnly = true}: HandleChannelInfoArgs) => {
let records: ChannelInfo[] = [];
if (!channelInfos.length) {
throw new DataOperatorException(
'An empty "channelInfos" array has been passed to the handleMyChannelSettings method',
@ -120,7 +126,7 @@ const ChannelHandler = (superclass: any) => class extends superclass {
key: 'channel_id',
});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'channel_id',
findMatchingRecordBy: isRecordChannelInfoEqualToRaw,
operator: prepareChannelInfoRecord,
@ -129,7 +135,7 @@ const ChannelHandler = (superclass: any) => class extends superclass {
tableName: CHANNEL_INFO,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -138,9 +144,11 @@ const ChannelHandler = (superclass: any) => class extends superclass {
* @param {RawMyChannel[]} myChannelsArgs.myChannels
* @param {boolean} myChannelsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {MyChannel[]| boolean}
* @returns {MyChannel[]}
*/
handleMyChannel = async ({myChannels, prepareRecordsOnly = true}: HandleMyChannelArgs) => {
let records: MyChannel[] = [];
if (!myChannels.length) {
throw new DataOperatorException(
'An empty "myChannels" array has been passed to the handleMyChannel method',
@ -152,7 +160,7 @@ const ChannelHandler = (superclass: any) => class extends superclass {
key: 'channel_id',
});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'channel_id',
findMatchingRecordBy: isRecordMyChannelEqualToRaw,
operator: prepareMyChannelRecord,
@ -161,7 +169,7 @@ const ChannelHandler = (superclass: any) => class extends superclass {
tableName: MY_CHANNEL,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
};

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DataOperator} from '@database/operator';
import DatabaseManager from '@database/manager';
import Operator from '@database/operator';
import {
isRecordGroupEqualToRaw,
isRecordGroupMembershipEqualToRaw,
@ -15,20 +16,39 @@ import {
prepareGroupsInTeamRecord,
} from '@database/operator/prepareRecords/group';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {DatabaseType} from '@typings/database/enums';
jest.mock('@database/manager');
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('*** Operator: Group Handlers tests ***', () => {
let databaseManagerClient: DatabaseManager;
let operatorClient: Operator;
beforeAll(async () => {
databaseManagerClient = new DatabaseManager();
const database = await databaseManagerClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: 'base_handler',
dbType: DatabaseType.SERVER,
serverUrl: 'baseHandler.test.com',
},
});
operatorClient = new Operator(database!);
});
it('=> HandleGroup: should write to GROUP entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'group_handler', setActive: true});
await DataOperator.handleGroup({
await operatorClient.handleGroup({
groups: [
{
id: 'id_groupdfjdlfkjdkfdsf',
@ -73,11 +93,11 @@ describe('*** Operator: Group Handlers tests ***', () => {
it('=> HandleGroupsInTeam: should write to GROUPS_IN_TEAM entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'group_handler', setActive: true});
await DataOperator.handleGroupsInTeam({
await operatorClient.handleGroupsInTeam({
groupsInTeams: [
{
team_id: 'team_899',
@ -118,11 +138,11 @@ describe('*** Operator: Group Handlers tests ***', () => {
it('=> HandleGroupsInChannel: should write to GROUPS_IN_CHANNEL entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'group_handler', setActive: true});
await DataOperator.handleGroupsInChannel({
await operatorClient.handleGroupsInChannel({
groupsInChannels: [
{
auto_add: true,
@ -173,11 +193,11 @@ describe('*** Operator: Group Handlers tests ***', () => {
it('=> HandleGroupMembership: should write to GROUP_MEMBERSHIP entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'group_handler', setActive: true});
await DataOperator.handleGroupMembership({
await operatorClient.handleGroupMembership({
groupMemberships: [
{
user_id: 'u4cprpki7ri81mbx8efixcsb8jo',

View file

@ -48,9 +48,11 @@ const GroupHandler = (superclass: any) => class extends superclass {
* @param {RawGroupMembership[]} groupMembershipsArgs.groupMemberships
* @param {boolean} groupMembershipsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {GroupMembership[] | boolean}
* @returns {GroupMembership[]}
*/
handleGroupMembership = async ({groupMemberships, prepareRecordsOnly = true}: HandleGroupMembershipArgs) => {
let records: GroupMembership[] = [];
if (!groupMemberships.length) {
throw new DataOperatorException(
'An empty "groupMemberships" array has been passed to the handleGroupMembership method',
@ -59,7 +61,7 @@ const GroupHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: groupMemberships, key: 'group_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'user_id',
findMatchingRecordBy: isRecordGroupMembershipEqualToRaw,
operator: prepareGroupMembershipRecord,
@ -68,7 +70,7 @@ const GroupHandler = (superclass: any) => class extends superclass {
tableName: GROUP_MEMBERSHIP,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -77,9 +79,11 @@ const GroupHandler = (superclass: any) => class extends superclass {
* @param {RawGroup[]} groupsArgs.groups
* @param {boolean} groupsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {Group[] | boolean}
* @returns {Group[]}
*/
handleGroup = async ({groups, prepareRecordsOnly = true}: HandleGroupArgs) => {
let records: Group[] = [];
if (!groups.length) {
throw new DataOperatorException(
'An empty "groups" array has been passed to the handleGroup method',
@ -88,7 +92,7 @@ const GroupHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: groups, key: 'name'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'name',
findMatchingRecordBy: isRecordGroupEqualToRaw,
operator: prepareGroupRecord,
@ -97,7 +101,7 @@ const GroupHandler = (superclass: any) => class extends superclass {
tableName: GROUP,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -106,9 +110,11 @@ const GroupHandler = (superclass: any) => class extends superclass {
* @param {RawGroupsInTeam[]} groupsInTeamsArgs.groupsInTeams
* @param {boolean} groupsInTeamsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {GroupsInTeam[] | boolean}
* @returns {GroupsInTeam[]}
*/
handleGroupsInTeam = async ({groupsInTeams, prepareRecordsOnly = true} : HandleGroupsInTeamArgs) => {
let records: GroupsInTeam[] = [];
if (!groupsInTeams.length) {
throw new DataOperatorException(
'An empty "groups" array has been passed to the handleGroupsInTeam method',
@ -117,7 +123,7 @@ const GroupHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: groupsInTeams, key: 'group_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'group_id',
findMatchingRecordBy: isRecordGroupsInTeamEqualToRaw,
operator: prepareGroupsInTeamRecord,
@ -126,7 +132,7 @@ const GroupHandler = (superclass: any) => class extends superclass {
tableName: GROUPS_IN_TEAM,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -135,9 +141,11 @@ const GroupHandler = (superclass: any) => class extends superclass {
* @param {RawGroupsInChannel[]} groupsInChannelsArgs.groupsInChannels
* @param {boolean} groupsInChannelsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {GroupsInChannel[] | boolean}
* @returns {GroupsInChannel[]}
*/
handleGroupsInChannel = async ({groupsInChannels, prepareRecordsOnly = true}: HandleGroupsInChannelArgs) => {
let records: GroupsInChannel[] = [];
if (!groupsInChannels.length) {
throw new DataOperatorException(
'An empty "groups" array has been passed to the handleGroupsInTeam method',
@ -146,7 +154,7 @@ const GroupHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: groupsInChannels, key: 'channel_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'group_id',
findMatchingRecordBy: isRecordGroupsInChannelEqualToRaw,
operator: prepareGroupsInChannelRecord,
@ -155,7 +163,7 @@ const GroupHandler = (superclass: any) => class extends superclass {
tableName: GROUPS_IN_CHANNEL,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
};

View file

@ -1,27 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DataOperator} from '@database/operator';
import DatabaseManager from '@database/manager';
import Operator from '@database/operator';
import {isRecordDraftEqualToRaw} from '@database/operator/comparators';
import {prepareDraftRecord} from '@database/operator/prepareRecords/post';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {DatabaseType} from '@typings/database/enums';
jest.mock('@database/manager');
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('*** Operator: Post Handlers tests ***', () => {
let databaseManagerClient: DatabaseManager;
let operatorClient: Operator;
beforeAll(async () => {
databaseManagerClient = new DatabaseManager();
const database = await databaseManagerClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: 'base_handler',
dbType: DatabaseType.SERVER,
serverUrl: 'baseHandler.test.com',
},
});
operatorClient = new Operator(database!);
});
it('=> HandleDraft: should write to the Draft entity', async () => {
expect.assertions(1);
await createTestConnection({databaseName: 'post_handler', setActive: true});
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
const values = [
{
channel_id: '4r9jmr7eqt8dxq3f9woypzurrychannelid',
files: [
{
id: '322dxx',
user_id: 'user_id',
post_id: 'post_id',
create_at: 123,
@ -42,7 +63,7 @@ describe('*** Operator: Post Handlers tests ***', () => {
},
];
await DataOperator.handleDraft({drafts: values, prepareRecordsOnly: false});
await operatorClient.handleDraft({drafts: values, prepareRecordsOnly: false});
expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({
findMatchingRecordBy: isRecordDraftEqualToRaw,
@ -206,17 +227,17 @@ describe('*** Operator: Post Handlers tests ***', () => {
},
];
const spyOnHandleFiles = jest.spyOn(DataOperator as any, 'handleFiles');
const spyOnHandlePostMetadata = jest.spyOn(DataOperator as any, 'handlePostMetadata');
const spyOnHandleReactions = jest.spyOn(DataOperator as any, 'handleReactions');
const spyOnHandleCustomEmojis = jest.spyOn(DataOperator as any, 'handleIsolatedEntity');
const spyOnHandlePostsInThread = jest.spyOn(DataOperator as any, 'handlePostsInThread');
const spyOnHandlePostsInChannel = jest.spyOn(DataOperator as any, 'handlePostsInChannel');
const spyOnHandleFiles = jest.spyOn(operatorClient as any, 'handleFiles');
const spyOnHandlePostMetadata = jest.spyOn(operatorClient as any, 'handlePostMetadata');
const spyOnHandleReactions = jest.spyOn(operatorClient as any, 'handleReactions');
const spyOnHandleCustomEmojis = jest.spyOn(operatorClient as any, 'handleIsolatedEntity');
const spyOnHandlePostsInThread = jest.spyOn(operatorClient as any, 'handlePostsInThread');
const spyOnHandlePostsInChannel = jest.spyOn(operatorClient as any, 'handlePostsInChannel');
await createTestConnection({databaseName: 'post_handler', setActive: true});
// handlePosts will in turn call handlePostsInThread
await DataOperator.handlePosts({
await operatorClient.handlePosts({
orders: [
'8swgtrrdiff89jnsiwiip3y1eoe',
'8fcnk3p1jt8mmkaprgajoxz115a',

View file

@ -1,6 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import Model from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import DataOperatorException from '@database/exceptions/data_operator_exception';
import {isRecordDraftEqualToRaw, isRecordPostEqualToRaw} from '@database/operator/comparators';
@ -14,8 +17,6 @@ import {
} from '@database/operator/prepareRecords/post';
import {getRawRecordPairs, getUniqueRawsBy, retrieveRecords} from '@database/operator/utils/general';
import {createPostsChain, sanitizePosts} from '@database/operator/utils/post';
import {Q} from '@nozbe/watermelondb';
import Model from '@nozbe/watermelondb/Model';
import {
HandleDraftArgs,
HandleFilesArgs,
@ -64,9 +65,11 @@ const PostHandler = (superclass: any) => class extends superclass {
* @param {RawDraft[]} draftsArgs.drafts
* @param {boolean} draftsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {Draft[] | boolean}
* @returns {Draft[]}
*/
handleDraft = async ({drafts, prepareRecordsOnly = true}: HandleDraftArgs) => {
let records: Draft[] = [];
if (!drafts.length) {
throw new DataOperatorException(
'An empty "drafts" array has been passed to the handleReactions method',
@ -75,7 +78,7 @@ const PostHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: drafts, key: 'channel_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'channel_id',
findMatchingRecordBy: isRecordDraftEqualToRaw,
operator: prepareDraftRecord,
@ -84,7 +87,7 @@ const PostHandler = (superclass: any) => class extends superclass {
tableName: DRAFT,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DataOperator} from '@database/operator';
import DatabaseManager from '@database/manager';
import Operator from '@database/operator';
import {
isRecordMyTeamEqualToRaw,
isRecordSlashCommandEqualToRaw,
@ -19,20 +20,39 @@ import {
prepareTeamSearchHistoryRecord,
} from '@database/operator/prepareRecords/team';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {DatabaseType} from '@typings/database/enums';
jest.mock('@database/manager');
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('*** Operator: Team Handlers tests ***', () => {
let databaseManagerClient: DatabaseManager;
let operatorClient: Operator;
beforeAll(async () => {
databaseManagerClient = new DatabaseManager();
const database = await databaseManagerClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: 'base_handler',
dbType: DatabaseType.SERVER,
serverUrl: 'baseHandler.test.com',
},
});
operatorClient = new Operator(database!);
});
it('=> HandleTeam: should write to TEAM entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'team_handler', setActive: true});
await DataOperator.handleTeam({
await operatorClient.handleTeam({
teams: [
{
id: 'rcgiyftm7jyrxnmdfdfa1osd8zswby',
@ -89,11 +109,11 @@ describe('*** Operator: Team Handlers tests ***', () => {
it('=> HandleTeamMemberships: should write to TEAM_MEMBERSHIP entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'team_handler', setActive: true});
await DataOperator.handleTeamMemberships({
await operatorClient.handleTeamMemberships({
teamMemberships: [
{
team_id: 'a',
@ -134,11 +154,11 @@ describe('*** Operator: Team Handlers tests ***', () => {
it('=> HandleMyTeam: should write to MY_TEAM entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'team_handler', setActive: true});
await DataOperator.handleMyTeam({
await operatorClient.handleMyTeam({
myTeams: [
{
team_id: 'teamA',
@ -171,11 +191,11 @@ describe('*** Operator: Team Handlers tests ***', () => {
it('=> HandleTeamChannelHistory: should write to TEAM_CHANNEL_HISTORY entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'team_handler', setActive: true});
await DataOperator.handleTeamChannelHistory({
await operatorClient.handleTeamChannelHistory({
teamChannelHistories: [
{
team_id: 'a',
@ -199,11 +219,11 @@ describe('*** Operator: Team Handlers tests ***', () => {
it('=> HandleTeamSearchHistory: should write to TEAM_SEARCH_HISTORY entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'team_handler', setActive: true});
await DataOperator.handleTeamSearchHistory({
await operatorClient.handleTeamSearchHistory({
teamSearchHistories: [
{
team_id: 'a',
@ -236,11 +256,11 @@ describe('*** Operator: Team Handlers tests ***', () => {
it('=> HandleSlashCommand: should write to SLASH_COMMAND entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'team_handler', setActive: true});
await DataOperator.handleSlashCommand({
await operatorClient.handleSlashCommand({
slashCommands: [
{
id: 'command_1',

View file

@ -33,6 +33,7 @@ import SlashCommand from '@typings/database/slash_command';
import Team from '@typings/database/team';
import TeamChannelHistory from '@typings/database/team_channel_history';
import TeamMembership from '@typings/database/team_membership';
import TeamSearchHistory from '@typings/database/team_search_history';
const {
MY_TEAM,
@ -44,11 +45,11 @@ const {
} = MM_TABLES.SERVER;
export interface TeamHandlerMix {
handleTeamMemberships : ({teamMemberships, prepareRecordsOnly}: HandleTeamMembershipArgs) => TeamMembership[] | boolean,
handleTeam: ({teams, prepareRecordsOnly}: HandleTeamArgs) => Team[] | boolean
handleTeamChannelHistory : ({teamChannelHistories, prepareRecordsOnly}: HandleTeamChannelHistoryArgs) => TeamChannelHistory[]| boolean,
handleSlashCommand : ({slashCommands, prepareRecordsOnly} : HandleSlashCommandArgs) => SlashCommand[]| boolean,
handleMyTeam : ({myTeams, prepareRecordsOnly}: HandleMyTeamArgs) => MyTeam[]| boolean
handleTeamMemberships: ({teamMemberships, prepareRecordsOnly}: HandleTeamMembershipArgs) => TeamMembership[];
handleTeam: ({teams, prepareRecordsOnly}: HandleTeamArgs) => Team[];
handleTeamChannelHistory: ({teamChannelHistories, prepareRecordsOnly}: HandleTeamChannelHistoryArgs) => TeamChannelHistory[];
handleSlashCommand: ({slashCommands, prepareRecordsOnly}: HandleSlashCommandArgs) => SlashCommand[];
handleMyTeam: ({myTeams, prepareRecordsOnly}: HandleMyTeamArgs) => MyTeam[];
}
const TeamHandler = (superclass: any) => class extends superclass {
@ -58,9 +59,11 @@ const TeamHandler = (superclass: any) => class extends superclass {
* @param {RawTeamMembership[]} teamMembershipsArgs.teamMemberships
* @param {boolean} teamMembershipsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {TeamMembership[] | boolean}
* @returns {TeamMembership[]}
*/
handleTeamMemberships = async ({teamMemberships, prepareRecordsOnly = true}: HandleTeamMembershipArgs) => {
let records: TeamMembership[] = [];
if (!teamMemberships.length) {
throw new DataOperatorException(
'An empty "teamMemberships" array has been passed to the handleTeamMemberships method',
@ -69,7 +72,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: teamMemberships, key: 'team_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'user_id',
findMatchingRecordBy: isRecordTeamMembershipEqualToRaw,
operator: prepareTeamMembershipRecord,
@ -78,7 +81,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
prepareRecordsOnly,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -87,9 +90,11 @@ const TeamHandler = (superclass: any) => class extends superclass {
* @param {RawTeam[]} teamsArgs.teams
* @param {boolean} teamsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {Team[] | boolean}
* @returns {Team[]}
*/
handleTeam = async ({teams, prepareRecordsOnly = true}: HandleTeamArgs) => {
let records: Team[] = [];
if (!teams.length) {
throw new DataOperatorException(
'An empty "teams" array has been passed to the handleTeam method',
@ -98,7 +103,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: teams, key: 'id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'id',
findMatchingRecordBy: isRecordTeamEqualToRaw,
operator: prepareTeamRecord,
@ -107,7 +112,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
tableName: TEAM,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -116,9 +121,11 @@ const TeamHandler = (superclass: any) => class extends superclass {
* @param {RawTeamChannelHistory[]} teamChannelHistoriesArgs.teamChannelHistories
* @param {boolean} teamChannelHistoriesArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {TeamChannelHistory[]| boolean}
* @returns {TeamChannelHistory[]}
*/
handleTeamChannelHistory = async ({teamChannelHistories, prepareRecordsOnly = true}: HandleTeamChannelHistoryArgs) => {
let records: TeamChannelHistory[] = [];
if (!teamChannelHistories.length) {
throw new DataOperatorException(
'An empty "teamChannelHistories" array has been passed to the handleTeamChannelHistory method',
@ -127,7 +134,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: teamChannelHistories, key: 'team_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'team_id',
findMatchingRecordBy: isRecordTeamChannelHistoryEqualToRaw,
operator: prepareTeamChannelHistoryRecord,
@ -136,7 +143,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
tableName: TEAM_CHANNEL_HISTORY,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -145,9 +152,11 @@ const TeamHandler = (superclass: any) => class extends superclass {
* @param {RawTeamSearchHistory[]} teamSearchHistoriesArgs.teamSearchHistories
* @param {boolean} teamSearchHistoriesArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {TeamSearchHistory[]| boolean}
* @returns {TeamSearchHistory[]}
*/
handleTeamSearchHistory = async ({teamSearchHistories, prepareRecordsOnly = true}: HandleTeamSearchHistoryArgs) => {
let records: TeamSearchHistory[] = [];
if (!teamSearchHistories.length) {
throw new DataOperatorException(
'An empty "teamSearchHistories" array has been passed to the handleTeamSearchHistory method',
@ -156,7 +165,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: teamSearchHistories, key: 'term'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'team_id',
findMatchingRecordBy: isRecordTeamSearchHistoryEqualToRaw,
operator: prepareTeamSearchHistoryRecord,
@ -165,7 +174,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
tableName: TEAM_SEARCH_HISTORY,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -174,9 +183,11 @@ const TeamHandler = (superclass: any) => class extends superclass {
* @param {RawSlashCommand[]} slashCommandsArgs.slashCommands
* @param {boolean} slashCommandsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {SlashCommand[]| boolean}
* @returns {SlashCommand[]}
*/
handleSlashCommand = async ({slashCommands, prepareRecordsOnly = true} : HandleSlashCommandArgs) => {
handleSlashCommand = async ({slashCommands, prepareRecordsOnly = true}: HandleSlashCommandArgs) => {
let records: SlashCommand[] = [];
if (!slashCommands.length) {
throw new DataOperatorException(
'An empty "slashCommands" array has been passed to the handleSlashCommand method',
@ -185,7 +196,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: slashCommands, key: 'id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'id',
findMatchingRecordBy: isRecordSlashCommandEqualToRaw,
operator: prepareSlashCommandRecord,
@ -194,7 +205,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
tableName: SLASH_COMMAND,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -203,9 +214,11 @@ const TeamHandler = (superclass: any) => class extends superclass {
* @param {RawMyTeam[]} myTeamsArgs.myTeams
* @param {boolean} myTeamsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {MyTeam[]| boolean}
* @returns {MyTeam[]}
*/
handleMyTeam = async ({myTeams, prepareRecordsOnly = true}: HandleMyTeamArgs) => {
let records: MyTeam[] = [];
if (!myTeams.length) {
throw new DataOperatorException(
'An empty "myTeams" array has been passed to the handleSlashCommand method',
@ -214,7 +227,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: myTeams, key: 'team_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'team_id',
findMatchingRecordBy: isRecordMyTeamEqualToRaw,
operator: prepareMyTeamRecord,
@ -223,7 +236,7 @@ const TeamHandler = (superclass: any) => class extends superclass {
tableName: MY_TEAM,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
};

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DataOperator} from '@database/operator';
import DatabaseManager from '@database/manager';
import Operator from '@database/operator';
import {
isRecordChannelMembershipEqualToRaw,
isRecordPreferenceEqualToRaw,
@ -13,21 +14,40 @@ import {
prepareUserRecord,
} from '@database/operator/prepareRecords/user';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {DatabaseType} from '@typings/database/enums';
jest.mock('@database/manager');
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('*** Operator: User Handlers tests ***', () => {
let databaseManagerClient: DatabaseManager;
let operatorClient: Operator;
beforeAll(async () => {
databaseManagerClient = new DatabaseManager();
const database = await databaseManagerClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
dbName: 'base_handler',
dbType: DatabaseType.SERVER,
serverUrl: 'baseHandler.test.com',
},
});
operatorClient = new Operator(database!);
});
it('=> HandleReactions: should write to both Reactions and CustomEmoji entities', async () => {
expect.assertions(2);
await createTestConnection({databaseName: 'user_handler', setActive: true});
const spyOnPrepareRecords = jest.spyOn(DataOperator as any, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(DataOperator as any, 'batchOperations');
const spyOnPrepareRecords = jest.spyOn(operatorClient as any, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(operatorClient as any, 'batchOperations');
await DataOperator.handleReactions({
await operatorClient.handleReactions({
reactions: [
{
create_at: 1608263728086,
@ -88,16 +108,16 @@ describe('*** Operator: User Handlers tests ***', () => {
timezone: {
automaticTimezone: 'Indian/Mauritius',
manualTimezone: '',
useAutomaticTimezone: true,
useAutomaticTimezone: '',
},
},
];
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'user_handler', setActive: true});
await DataOperator.handleUsers({users, prepareRecordsOnly: false});
await operatorClient.handleUsers({users, prepareRecordsOnly: false});
expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1);
expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({
@ -139,7 +159,7 @@ describe('*** Operator: User Handlers tests ***', () => {
timezone: {
automaticTimezone: 'Indian/Mauritius',
manualTimezone: '',
useAutomaticTimezone: true,
useAutomaticTimezone: '',
},
},
],
@ -153,11 +173,11 @@ describe('*** Operator: User Handlers tests ***', () => {
it('=> HandlePreferences: should write to PREFERENCE entity', async () => {
expect.assertions(2);
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'user_handler', setActive: true});
await DataOperator.handlePreferences({
await operatorClient.handlePreferences({
preferences: [
{
user_id: '9ciscaqbrpd6d8s68k76xb9bte',
@ -269,11 +289,11 @@ describe('*** Operator: User Handlers tests ***', () => {
},
];
const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords');
const spyOnHandleEntityRecords = jest.spyOn(operatorClient as any, 'handleEntityRecords');
await createTestConnection({databaseName: 'user_handler', setActive: true});
await DataOperator.handleChannelMembership({
await operatorClient.handleChannelMembership({
channelMemberships,
prepareRecordsOnly: false,
});

View file

@ -17,7 +17,6 @@ import {
} from '@database/operator/prepareRecords/user';
import {getRawRecordPairs, getUniqueRawsBy} from '@database/operator/utils/general';
import {sanitizeReactions} from '@database/operator/utils/reaction';
import Model from '@nozbe/watermelondb/Model';
import ChannelMembership from '@typings/database/channel_membership';
import CustomEmoji from '@typings/database/custom_emoji';
import {
@ -40,10 +39,10 @@ const {
} = MM_TABLES.SERVER;
export interface UserHandlerMix {
handleChannelMembership : ({channelMemberships, prepareRecordsOnly}: HandleChannelMembershipArgs) => ChannelMembership[] | boolean,
handlePreferences : ({preferences, prepareRecordsOnly}: HandlePreferencesArgs) => Preference[] | boolean,
handleReactions : ({reactions, prepareRecordsOnly}: HandleReactionsArgs) => boolean | (Reaction | CustomEmoji)[],
handleUsers : ({users, prepareRecordsOnly}: HandleUsersArgs) => User[] | boolean
handleChannelMembership : ({channelMemberships, prepareRecordsOnly}: HandleChannelMembershipArgs) => ChannelMembership[];
handlePreferences : ({preferences, prepareRecordsOnly}: HandlePreferencesArgs) => Preference[];
handleReactions : ({reactions, prepareRecordsOnly}: HandleReactionsArgs) =>(Reaction | CustomEmoji)[];
handleUsers : ({users, prepareRecordsOnly}: HandleUsersArgs) => User[];
}
const UserHandler = (superclass: any) => class extends superclass {
@ -53,21 +52,20 @@ const UserHandler = (superclass: any) => class extends superclass {
* @param {RawChannelMembership[]} channelMembershipsArgs.channelMemberships
* @param {boolean} channelMembershipsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {ChannelMembership[] | boolean}
* @returns {ChannelMembership[]}
*/
handleChannelMembership = async ({channelMemberships, prepareRecordsOnly = true}: HandleChannelMembershipArgs) => {
let records: ChannelMembership[] = [];
if (!channelMemberships.length) {
throw new DataOperatorException(
'An empty "channelMemberships" array has been passed to the handleChannelMembership method',
);
}
const rawValues = getUniqueRawsBy({
raws: channelMemberships,
key: 'channel_id',
});
const rawValues = getUniqueRawsBy({raws: channelMemberships, key: 'channel_id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'user_id',
findMatchingRecordBy: isRecordChannelMembershipEqualToRaw,
operator: prepareChannelMembershipRecord,
@ -76,7 +74,7 @@ const UserHandler = (superclass: any) => class extends superclass {
tableName: CHANNEL_MEMBERSHIP,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -85,9 +83,11 @@ const UserHandler = (superclass: any) => class extends superclass {
* @param {RawPreference[]} preferencesArgs.preferences
* @param {boolean} preferencesArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {Preference[] | boolean}
* @returns {Preference[]}
*/
handlePreferences = async ({preferences, prepareRecordsOnly = true}: HandlePreferencesArgs) => {
let records: Preference[] = [];
if (!preferences.length) {
throw new DataOperatorException(
'An empty "preferences" array has been passed to the handlePreferences method',
@ -96,7 +96,7 @@ const UserHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: preferences, key: 'name'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'user_id',
findMatchingRecordBy: isRecordPreferenceEqualToRaw,
operator: preparePreferenceRecord,
@ -105,7 +105,7 @@ const UserHandler = (superclass: any) => class extends superclass {
tableName: PREFERENCE,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
/**
@ -114,9 +114,11 @@ const UserHandler = (superclass: any) => class extends superclass {
* @param {RawReaction[]} handleReactions.reactions
* @param {boolean} handleReactions.prepareRecordsOnly
* @throws DataOperatorException
* @returns {boolean | (Reaction | CustomEmoji)[]}
* @returns {(Reaction| CustomEmoji)[]}
*/
handleReactions = async ({reactions, prepareRecordsOnly}: HandleReactionsArgs) => {
let batchRecords: (Reaction| CustomEmoji)[] = [];
if (!reactions.length) {
throw new DataOperatorException(
'An empty "reactions" array has been passed to the handleReactions method',
@ -137,8 +139,6 @@ const UserHandler = (superclass: any) => class extends superclass {
rawReactions: rawValues,
});
let batchRecords: Model[] = [];
if (createReactions.length) {
// Prepares record for model Reactions
const reactionsRecords = (await this.prepareRecords({
@ -174,7 +174,7 @@ const UserHandler = (superclass: any) => class extends superclass {
});
}
return false;
return [];
};
/**
@ -183,9 +183,11 @@ const UserHandler = (superclass: any) => class extends superclass {
* @param {RawUser[]} usersArgs.users
* @param {boolean} usersArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {User[] | boolean}
* @returns {User[]}
*/
handleUsers = async ({users, prepareRecordsOnly = true}: HandleUsersArgs) => {
let records: User[] = [];
if (!users.length) {
throw new DataOperatorException(
'An empty "users" array has been passed to the handleUsers method',
@ -194,7 +196,7 @@ const UserHandler = (superclass: any) => class extends superclass {
const rawValues = getUniqueRawsBy({raws: users, key: 'id'});
const records = await this.handleEntityRecords({
records = await this.handleEntityRecords({
fieldName: 'id',
findMatchingRecordBy: isRecordUserEqualToRaw,
operator: prepareUserRecord,
@ -203,7 +205,7 @@ const UserHandler = (superclass: any) => class extends superclass {
prepareRecordsOnly,
});
return prepareRecordsOnly && records?.length && records;
return records;
};
};

View file

@ -7,18 +7,20 @@ import GroupHandler, {GroupHandlerMix} from '@database/operator/handlers/group';
import PostHandler, {PostHandlerMix} from '@database/operator/handlers/post';
import TeamHandler, {TeamHandlerMix} from '@database/operator/handlers/team';
import UserHandler, {UserHandlerMix} from '@database/operator/handlers/user';
import {DatabaseInstance} from '@typings/database/database';
import mix from '@utils/mix';
interface Operator extends BaseHandlerMix, PostHandlerMix, UserHandlerMix, GroupHandlerMix, ChannelHandlerMix, TeamHandlerMix {}
class Operator extends mix(BaseHandler).with(
PostHandler,
UserHandler,
GroupHandler,
ChannelHandler,
TeamHandler,
) {}
class Operator extends mix(BaseHandler).with(PostHandler, UserHandler, GroupHandler, ChannelHandler, TeamHandler) {
database: DatabaseInstance;
const DataOperator = new Operator();
constructor(activeDatabase?: DatabaseInstance) {
super();
if (activeDatabase) {
this.activeDatabase = activeDatabase;
}
}
}
export {DataOperator, Operator};
export default Operator;

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {
prepareAppRecord,
prepareCustomEmojiRecord,
@ -11,14 +12,21 @@ import {
prepareTermsOfServiceRecord,
} from '@database/operator/prepareRecords/general';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import DatabaseManager from '@database/manager';
import {OperationType} from '@typings/database/enums';
jest.mock('@database/manager');
describe('*** Isolated Prepare Records Test ***', () => {
let databaseManagerClient: DatabaseManager;
beforeAll(async () => {
databaseManagerClient = new DatabaseManager();
});
it('=> prepareAppRecord: should return an array of type App', async () => {
expect.assertions(3);
const database = await DatabaseManager.getDefaultDatabase();
const database = await databaseManagerClient.getDefaultDatabase();
expect(database).toBeTruthy();
const preparedRecords = await prepareAppRecord({
@ -41,7 +49,7 @@ describe('*** Isolated Prepare Records Test ***', () => {
it('=> prepareGlobalRecord: should return an array of type Global', async () => {
expect.assertions(3);
const database = await DatabaseManager.getDefaultDatabase();
const database = await databaseManagerClient.getDefaultDatabase();
expect(database).toBeTruthy();
const preparedRecords = await prepareGlobalRecord({
@ -60,7 +68,7 @@ describe('*** Isolated Prepare Records Test ***', () => {
it('=> prepareServersRecord: should return an array of type Servers', async () => {
expect.assertions(3);
const database = await DatabaseManager.getDefaultDatabase();
const database = await databaseManagerClient.getDefaultDatabase();
expect(database).toBeTruthy();
const preparedRecords = await prepareServersRecord({

View file

@ -104,7 +104,7 @@ export const preparePreferenceRecord = ({action, database, value}: DataFactoryAr
// id of preference comes from server response
const generator = (preference: Preference) => {
preference._raw.id = isCreateAction ? (raw?.id ?? preference.id) : record.id;
preference._raw.id = isCreateAction ? preference.id : record.id;
preference.category = raw.category;
preference.name = raw.name;
preference.userId = raw.user_id;

View file

@ -9,7 +9,8 @@ jest.mock('@database/manager');
export const createTestConnection = async ({databaseName = 'db_name', setActive = false}) => {
const serverUrl = 'https://appv2.mattermost.com';
const database = await DatabaseManager.createDatabaseConnection({
const databaseClient = new DatabaseManager();
const database = await databaseClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
@ -20,10 +21,7 @@ export const createTestConnection = async ({databaseName = 'db_name', setActive
});
if (setActive) {
await DatabaseManager.setActiveServerDatabase({
displayName: databaseName,
serverUrl,
});
await databaseClient.setActiveServerDatabase(serverUrl);
}
return database;

View file

@ -23,7 +23,7 @@ import User from '@typings/database/user';
const {CHANNEL, POST, SLASH_COMMAND, TEAM, USER} = MM_TABLES.SERVER;
/**
* hasSimilarUpdateAt: Database Operations on some entities are expensive. As such, we would like to operate if and only if we are
* getValidRecordsForUpdate: Database Operations on some entities are expensive. As such, we would like to operate if and only if we are
* 100% sure that the records are actually different from what we already have in the database.
* @param {IdenticalRecordArgs} identicalRecord
* @param {string} identicalRecord.tableName
@ -31,16 +31,26 @@ const {CHANNEL, POST, SLASH_COMMAND, TEAM, USER} = MM_TABLES.SERVER;
* @param {Model} identicalRecord.existingRecord
* @returns {boolean}
*/
export const hasSimilarUpdateAt = ({tableName, newValue, existingRecord}: IdenticalRecordArgs) => {
export const getValidRecordsForUpdate = ({tableName, newValue, existingRecord}: IdenticalRecordArgs) => {
const guardTables = [CHANNEL, POST, SLASH_COMMAND, TEAM, USER];
if (guardTables.includes(tableName)) {
type Raw = RawPost | RawUser | RawTeam | RawSlashCommand | RawChannel;
type ExistingRecord = Post | User | Team | SlashCommand | Channel;
return (newValue as Raw).update_at === (existingRecord as ExistingRecord).updateAt;
const shouldUpdate = (newValue as Raw).update_at === (existingRecord as ExistingRecord).updateAt;
if (shouldUpdate) {
return {
record: existingRecord,
raw: newValue,
};
}
return false;
}
return {
record: existingRecord,
raw: newValue,
};
};
/**
@ -90,7 +100,7 @@ export const getUniqueRawsBy = ({raws, key}:{ raws: RawValue[], key: string}) =>
* @param {RetrieveRecordsArgs} records
* @param {Database} records.database
* @param {string} records.tableName
* @param {any} records.condition
* @param {Clause} records.condition
* @returns {Promise<Model[]>}
*/
export const retrieveRecords = ({database, tableName, condition}: RetrieveRecordsArgs) => {

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DataOperator} from '@database/operator';
import Operator from '@database/operator';
import {createPostsChain, sanitizePosts} from '@database/operator/utils/post';
import {sanitizeReactions} from '@database/operator/utils/reaction';
import DatabaseManager from '@database/manager';
@ -71,7 +71,8 @@ describe('DataOperator: Utils tests', () => {
it('=> sanitizeReactions: should triage between reactions that needs creation/deletion and emojis to be created', async () => {
const dbName = 'server_schema_connection';
const serverUrl = 'https://appv2.mattermost.com';
const database = await DatabaseManager.createDatabaseConnection({
const databaseManagerClient = new DatabaseManager();
const database = await databaseManagerClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,
@ -80,13 +81,12 @@ describe('DataOperator: Utils tests', () => {
serverUrl,
},
});
await DatabaseManager.setActiveServerDatabase({
displayName: dbName,
serverUrl,
});
await databaseManagerClient.setActiveServerDatabase(serverUrl);
const operatorClient = new Operator(database!);
// we commit one Reaction to our database
const prepareRecords = await DataOperator.handleReactions({
const prepareRecords = await operatorClient.handleReactions({
reactions: [
{
user_id: 'beqkgo4wzbn98kjzjgc1p5n91o',

View file

@ -1,38 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {Operator} from '@database/operator/index';
import Operator from '@database/operator/index';
import DatabaseManager from '@database/manager';
import DatabaseConnectionException from '@database/exceptions/database_connection_exception';
export const createDataOperator = async (serverUrl: string) => {
// Retrieves the connection matching serverUrl
const connections = await DatabaseManager.retrieveDatabaseInstances([
serverUrl,
]);
const databaseManagerClient = new DatabaseManager();
if (connections?.length) {
// finds the connection that corresponds to the serverUrl value
const index = connections.findIndex((databaseInstance) => {
return databaseInstance.url === serverUrl;
});
if (!connections?.[index]?.dbInstance) {
throw new DatabaseConnectionException(
`An instance of a database connection was found but we could not create a connection out of it for url: ${serverUrl}`,
);
}
const connection = connections[index].dbInstance as Database;
const operator = new Operator();
operator.setActiveDatabase(connection);
const connection = await databaseManagerClient.getDatabaseConnection({serverUrl, setAsActiveDatabase: false});
if (connection) {
const operator = new Operator(connection);
return operator;
}
throw new DatabaseConnectionException(
`No database has been registered with this url: ${serverUrl}`,
);

View file

@ -3,7 +3,6 @@
import DatabaseManager from '@database/manager';
import {createDataOperator} from '@database/operator/wrapper/index';
import DatabaseConnectionException from '@database/exceptions/database_connection_exception';
import {DatabaseType} from '@typings/database/enums';
jest.mock('@database/manager');
@ -11,13 +10,19 @@ jest.mock('@database/manager');
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('*** DataOperator Wrapper ***', () => {
let databaseManagerClient: DatabaseManager;
beforeAll(async () => {
databaseManagerClient = new DatabaseManager();
});
it('=> wrapper should return an instance of DataOperator ', async () => {
expect.assertions(1);
const serverUrl = 'https://wrapper.mattermost.com';
// first we create the connection and save it into default database
await DatabaseManager.createDatabaseConnection({
await databaseManagerClient.createDatabaseConnection({
configs: {
actionsEnabled: true,
dbName: 'community mattermost',
@ -32,29 +37,6 @@ describe('*** DataOperator Wrapper ***', () => {
expect(dataOperator).toBeTruthy();
});
it('=> wrapper should throw an error due to invalid server url', async () => {
expect.assertions(2);
const serverUrl = 'https://wrapper.mattermost.com';
// first we create the connection and save it into default database
await DatabaseManager.createDatabaseConnection({
configs: {
actionsEnabled: true,
dbName: 'test_database',
dbType: DatabaseType.SERVER,
serverUrl,
},
shouldAddToDefaultDatabase: true,
});
await expect(createDataOperator('https://error.com')).rejects.toThrow(
'No database has been registered with this url: https://error.com',
);
await expect(createDataOperator('https://error.com')).rejects.toThrow(DatabaseConnectionException);
});
it('=> wrapper to handlePosts [OTHER DATABASE]: should write to Post and its sub-child entities', async () => {
expect.assertions(12);
@ -212,7 +194,7 @@ describe('*** DataOperator Wrapper ***', () => {
];
// create connection to other server in default db
await DatabaseManager.createDatabaseConnection({
await databaseManagerClient.createDatabaseConnection({
shouldAddToDefaultDatabase: true,
configs: {
actionsEnabled: true,

View file

@ -27,7 +27,6 @@ export default tableSchema({
{name: 'roles', type: 'string'},
{name: 'status', type: 'string'},
{name: 'timezone', type: 'string'},
{name: 'user_id', type: 'string'},
{name: 'username', type: 'string'},
],
});

View file

@ -492,7 +492,6 @@ describe('*** Test schema for SERVER database ***', () => {
roles: {name: 'roles', type: 'string'},
status: {name: 'status', type: 'string'},
timezone: {name: 'timezone', type: 'string'},
user_id: {name: 'user_id', type: 'string'},
username: {name: 'username', type: 'string'},
},
columnArray: [
@ -513,7 +512,6 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'roles', type: 'string'},
{name: 'status', type: 'string'},
{name: 'timezone', type: 'string'},
{name: 'user_id', type: 'string'},
{name: 'username', type: 'string'},
],
},

View file

@ -2,82 +2,172 @@
// See LICENSE.txt for license information.
import moment from 'moment';
import {Platform} from 'react-native';
import {getLocales} from 'react-native-localize';
import en from '@assets/i18n/en.json';
export const DEFAULT_LOCALE = 'en';
const deviceLocale = getLocales()[0].languageCode;
export const DEFAULT_LOCALE = deviceLocale;
function loadTranslation(locale: string) {
function loadTranslation(locale?: string) {
try {
let translations;
let momentData;
switch (locale) {
case 'de':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/de');
require('@formatjs/intl-numberformat/locale-data/de');
require('@formatjs/intl-datetimeformat/locale-data/de');
}
translations = require('@assets/i18n/de.json');
momentData = require('moment/locale/de');
break;
case 'es':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/es');
require('@formatjs/intl-numberformat/locale-data/es');
require('@formatjs/intl-datetimeformat/locale-data/es');
}
translations = require('@assets/i18n/es.json');
momentData = require('moment/locale/es');
break;
case 'fr':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/fr');
require('@formatjs/intl-numberformat/locale-data/fr');
require('@formatjs/intl-datetimeformat/locale-data/fr');
}
translations = require('@assets/i18n/fr.json');
momentData = require('moment/locale/fr');
break;
case 'it':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/it');
require('@formatjs/intl-numberformat/locale-data/it');
require('@formatjs/intl-datetimeformat/locale-data/it');
}
translations = require('@assets/i18n/it.json');
momentData = require('moment/locale/it');
break;
case 'ja':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/ja');
require('@formatjs/intl-numberformat/locale-data/ja');
require('@formatjs/intl-datetimeformat/locale-data/ja');
}
translations = require('@assets/i18n/ja.json');
momentData = require('moment/locale/ja');
break;
case 'ko':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/ko');
require('@formatjs/intl-numberformat/locale-data/ko');
require('@formatjs/intl-datetimeformat/locale-data/ko');
}
translations = require('@assets/i18n/ko.json');
momentData = require('moment/locale/ko');
break;
case 'nl':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/nl');
require('@formatjs/intl-numberformat/locale-data/nl');
require('@formatjs/intl-datetimeformat/locale-data/nl');
}
translations = require('@assets/i18n/nl.json');
momentData = require('moment/locale/nl');
break;
case 'pl':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/pl');
require('@formatjs/intl-numberformat/locale-data/pl');
require('@formatjs/intl-datetimeformat/locale-data/pl');
}
translations = require('@assets/i18n/pl.json');
momentData = require('moment/locale/pl');
break;
case 'pt-BR':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/pt');
require('@formatjs/intl-numberformat/locale-data/pt');
require('@formatjs/intl-datetimeformat/locale-data/pt');
}
translations = require('@assets/i18n/pt-BR.json');
momentData = require('moment/locale/pt-br');
break;
case 'ro':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/ro');
require('@formatjs/intl-numberformat/locale-data/ro');
require('@formatjs/intl-datetimeformat/locale-data/ro');
}
translations = require('@assets/i18n/ro.json');
momentData = require('moment/locale/ro');
break;
case 'ru':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/ru');
require('@formatjs/intl-numberformat/locale-data/ru');
require('@formatjs/intl-datetimeformat/locale-data/ru');
}
translations = require('@assets/i18n/ru.json');
momentData = require('moment/locale/ru');
break;
case 'tr':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/tr');
require('@formatjs/intl-numberformat/locale-data/tr');
require('@formatjs/intl-datetimeformat/locale-data/tr');
}
translations = require('@assets/i18n/tr.json');
momentData = require('moment/locale/tr');
break;
case 'uk':
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/uk');
require('@formatjs/intl-numberformat/locale-data/uk');
require('@formatjs/intl-datetimeformat/locale-data/uk');
}
translations = require('@assets/i18n/uk.json');
momentData = require('moment/locale/uk');
break;
case 'zh-CN':
loadChinesePolyfills();
translations = require('@assets/i18n/zh-CN.json');
momentData = require('moment/locale/zh-cn');
break;
case 'zh-TW':
loadChinesePolyfills();
translations = require('@assets/i18n/zh-TW.json');
momentData = require('moment/locale/zh-tw');
break;
default:
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/en');
require('@formatjs/intl-numberformat/locale-data/en');
require('@formatjs/intl-datetimeformat/locale-data/en');
}
translations = en;
break;
}
if (momentData) {
if (momentData && locale) {
moment.updateLocale(locale.toLowerCase(), momentData);
} else {
resetMomentLocale();
@ -89,11 +179,19 @@ function loadTranslation(locale: string) {
}
}
function loadChinesePolyfills() {
if (Platform.OS === 'android') {
require('@formatjs/intl-pluralrules/locale-data/zh');
require('@formatjs/intl-numberformat/locale-data/zh');
require('@formatjs/intl-datetimeformat/locale-data/zh');
}
}
export function resetMomentLocale() {
moment.locale(DEFAULT_LOCALE);
}
export function getTranslations(locale: string) {
export function getTranslations(locale?: string) {
return loadTranslation(locale);
}

View file

@ -13,7 +13,7 @@ const isSystemAdmin = (roles: string) => {
const clientMap: Record<string, Analytics> = {};
class Analytics {
export class Analytics {
analytics: RudderClient | null = null;
context: any;
diagnosticId: string | undefined;

View file

@ -1,90 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import AsyncStorage from '@react-native-community/async-storage';
import * as KeyChain from 'react-native-keychain';
import DatabaseManager from '@database/manager';
import * as analytics from '@init/analytics';
import emmProvider from '@init/emm_provider';
import EphemeralStore from '@store/ephemeral_store';
import {getIOSAppGroupDetails} from '@utils/mattermost_managed';
import {getCSRFFromCookie} from '@utils/security';
import type {ServerCredentials} from '@typings/credentials';
const ASYNC_STORAGE_CURRENT_SERVER_KEY = '@currentServerUrl';
export const getCurrentServerUrl = async () => {
// TODO: Use default database to retrieve the current server url
// and fallback to AsyncStorage if needed
// TODO: This function is only necessary to support pre-Gekidou
// versions as the active server URL may be stored in AsyncStorage.
// At some point we can remove this function and rely solely on
// the database manager's `getActiveServerUrl`.
export const getActiveServerUrl = async () => {
let serverUrl: string | null | undefined;
const databaseManager = new DatabaseManager();
serverUrl = await databaseManager.getActiveServerUrl(); // TODO: need funciton to get active server url
if (!serverUrl) {
// If upgrading from non-Gekidou, the server URL might be in
// AsyncStorage. If so, retrieve the server URL, create a DB for it,
// then delete the AsyncStorage item.
serverUrl = await AsyncStorage.getItem(ASYNC_STORAGE_CURRENT_SERVER_KEY);
if (serverUrl) {
databaseManager.setActiveServerDatabase(serverUrl);
AsyncStorage.removeItem(ASYNC_STORAGE_CURRENT_SERVER_KEY);
}
}
const serverUrl = await AsyncStorage.getItem(ASYNC_STORAGE_CURRENT_SERVER_KEY);
EphemeralStore.currentServerUrl = serverUrl;
return serverUrl;
};
export const setAppCredentials = (deviceToken: string, currentUserId: string, token: string, url: string) => {
if (!currentUserId) {
export const setServerCredentials = (serverUrl: string, userId: string, token: string) => {
if (!(serverUrl && userId && token)) {
return;
}
// Only save to keychain if the url and token are set
if (url && token) {
try {
const username = `${deviceToken}, ${currentUserId}`;
const accessGroup = emmProvider.getAppGroupIdentifier();
let accessGroup;
if (Platform.OS === 'ios') {
const appGroup = getIOSAppGroupDetails();
accessGroup = appGroup.appGroupIdentifier;
}
EphemeralStore.deviceToken = deviceToken;
EphemeralStore.currentServerUrl = url;
AsyncStorage.setItem(ASYNC_STORAGE_CURRENT_SERVER_KEY, url);
const options: KeyChain.Options = {
accessGroup,
securityLevel: KeyChain.SECURITY_LEVEL.SECURE_SOFTWARE,
};
KeyChain.setInternetCredentials(url, username, token, options);
KeyChain.setInternetCredentials(serverUrl, userId, token, options);
} catch (e) {
console.warn('could not set credentials', e); //eslint-disable-line no-console
}
};
export const getActiveServerCredentials = async () => {
const serverUrl = await getActiveServerUrl();
if (serverUrl) {
return getServerCredentials(serverUrl);
}
return null;
};
export const getAppCredentials = async () => {
const serverUrl = await getCurrentServerUrl();
return getInternetCredentials(serverUrl || '');
};
export const removeAppCredentials = async () => {
const url = await getCurrentServerUrl();
export const removeServerCredentials = async (serverUrl: string) => {
// TODO: invalidate client and remove tokens
if (url) {
KeyChain.resetInternetCredentials(url);
}
KeyChain.resetInternetCredentials(serverUrl);
EphemeralStore.currentServerUrl = null;
AsyncStorage.removeItem(ASYNC_STORAGE_CURRENT_SERVER_KEY);
};
export async function getInternetCredentials(url: string) {
export const removeActiveServerCredentials = async () => {
const serverUrl = await getActiveServerUrl();
if (serverUrl) {
removeServerCredentials(serverUrl);
}
};
export const getServerCredentials = async (serverUrl: string): Promise<ServerCredentials|null> => {
try {
const credentials = await KeyChain.getInternetCredentials(url);
const credentials = await KeyChain.getInternetCredentials(serverUrl);
if (credentials) {
const usernameParsed = credentials.username.split(',');
// TODO: Pre-Gekidou we were concatenating the deviceToken and the userId in
// credentials.username so we need to check the length of credentials.username.split(',').
// This check should be removed at some point.
const parts = credentials.username.split(',');
const userId = parts[parts.length - 1];
const token = credentials.password;
const [deviceToken, currentUserId] = usernameParsed;
if (token && token !== 'undefined') {
EphemeralStore.deviceToken = deviceToken;
const analyticsClient = analytics.get(url);
analyticsClient?.setUserId(currentUserId);
const analyticsClient = analytics.get(serverUrl);
analyticsClient?.setUserId(userId);
const csrf = await getCSRFFromCookie(url);
const csrf = await getCSRFFromCookie(serverUrl);
// eslint-disable-next-line no-console
console.log('CSRF', csrf);
// TODO: Create client and set token / CSRF
return credentials;
return {userId, token};
}
}
@ -92,4 +114,4 @@ export async function getInternetCredentials(url: string) {
} catch (e) {
return null;
}
}
};

View file

@ -1,212 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import {Alert, AlertButton, AppState, AppStateStatus, NativeModules, Platform} from 'react-native';
import JailMonkey from 'jail-monkey';
import LocalConfig from '@assets/config.json';
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n';
import {getAppCredentials} from './credentials';
const PROMPT_IN_APP_PIN_CODE_AFTER = 5 * 1000;
class EMMProvider {
allowOtherServers = true;
appGroupIdentifier: string | undefined;
backgroundSince = 0;
blurApplicationScreen = false;
enabled = false;
inAppPinCode = false;
jailbreakProtection = false;
performingAuthentication = false;
previousAppState: AppStateStatus | undefined;
vendor = 'Mattermost';
constructor() {
Emm.addListener(this.handleManagedConfig);
AppState.addEventListener('change', this.onAppStateChange);
if (Platform.OS === 'ios') {
const {MattermostManaged} = NativeModules;
if (MattermostManaged) {
this.appGroupIdentifier = MattermostManaged.appGroupIdentifier;
}
if (this.appGroupIdentifier) {
Emm.setAppGroupId(this.appGroupIdentifier);
}
}
}
checkIfDeviceIsTrusted = () => {
const isTrusted = this.isTrustedDevice();
if (!isTrusted) {
const locale = DEFAULT_LOCALE; // TODO: Get current user or system locale
const translations = getTranslations(locale);
Alert.alert(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', this.vendor),
translations[t('mobile.managed.jailbreak')].replace('{vendor}', this.vendor),
[{
text: translations[t('mobile.managed.exit')],
style: 'destructive',
onPress: () => {
Emm.exitApp();
},
}],
{cancelable: false},
);
}
};
getAppGroupIdentifier = () => {
return this.appGroupIdentifier;
};
handleAuthentication = async (prompt = true) => {
this.performingAuthentication = true;
const isSecured = await Emm.isDeviceSecured();
const locale = DEFAULT_LOCALE;
const translations = getTranslations(locale);
if (isSecured) {
try {
if (prompt) {
await Emm.authenticate({
reason: translations[t('mobile.managed.secured_by')].replace('{vendor}', this.vendor),
fallback: true,
supressEnterPassword: true,
});
}
} catch (err) {
Emm.exitApp();
return false;
}
} else {
await this.showNotSecuredAlert(translations);
Emm.exitApp();
return false;
}
this.performingAuthentication = false;
return true;
};
handleManagedConfig = async () => {
if (this.performingAuthentication) {
return true;
}
if (LocalConfig.AutoSelectServerUrl) {
// TODO: Set server url based on EMM config
this.allowOtherServers = false;
}
const managedConfig = await Emm.getManagedConfig();
if (managedConfig && Object.keys(managedConfig).length) {
this.enabled = true;
this.inAppPinCode = managedConfig.inAppPinCode === 'true';
this.blurApplicationScreen = managedConfig.blurApplicationScreen === 'true';
this.jailbreakProtection = managedConfig.jailbreakProtection === 'true';
if (managedConfig.vendor) {
this.vendor = managedConfig.vendor;
}
const credentials = await getAppCredentials();
if (!credentials) {
if (managedConfig.serverUrl) {
// TODO: Set server url based on EMM config
}
if (managedConfig.allowOtherServers && managedConfig.allowOtherServers === 'false') {
this.allowOtherServers = false;
}
}
if (this.blurApplicationScreen) {
Emm.enableBlurScreen(true);
}
}
return true;
};
isTrustedDevice = () => {
if (__DEV__) {
return true;
}
return JailMonkey.trustFall();
};
onAppStateChange = async (appState: AppStateStatus) => {
const isActive = appState === 'active';
const isBackground = appState === 'background';
if (isActive && (!this.enabled || this.previousAppState === 'background')) {
// if the app is being controlled by an EMM provider
if (this.enabled && this.inAppPinCode) {
const authExpired = (Date.now() - this.backgroundSince) >= PROMPT_IN_APP_PIN_CODE_AFTER;
// Once the app becomes active we check if the device needs to have a passcode set
// if more than 5 minutes have passed prompt for passcode
const prompt = this.backgroundSince > 0 && authExpired;
await this.handleAuthentication(prompt);
}
this.backgroundSince = 0;
} else if (isBackground) {
this.backgroundSince = Date.now();
}
this.previousAppState = appState;
};
showNotSecuredAlert = (translations: Record<string, string>) => {
return new Promise(async (resolve) => { /* eslint-disable-line no-async-promise-executor */
const buttons: AlertButton[] = [];
if (Platform.OS === 'android') {
buttons.push({
text: translations[t('mobile.managed.settings')],
onPress: () => {
Emm.openSecuritySettings();
},
});
}
buttons.push({
text: translations[t('mobile.managed.exit')],
onPress: resolve,
style: 'cancel',
});
let message;
if (Platform.OS === 'ios') {
const {face} = await Emm.deviceSecureWith();
if (face) {
message = translations[t('mobile.managed.not_secured.ios')];
} else {
message = translations[t('mobile.managed.not_secured.ios.touchId')];
}
} else {
message = translations[t('mobile.managed.not_secured.android')];
}
Alert.alert(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', this.vendor),
message,
buttons,
{cancelable: false, onDismiss: () => resolve},
);
});
};
}
export default new EMMProvider();

145
app/init/fetch.js Normal file
View file

@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DeviceInfo from 'react-native-device-info';
import RNFetchBlob from 'rn-fetch-blob';
import urlParse from 'url-parse';
import LocalConfig from '@assets/config';
import {Client4} from '@client/rest';
import {HEADER_TOKEN, HEADER_X_CLUSTER_ID, HEADER_X_VERSION_ID} from '@client/rest/constants';
import ClientError from '@client/rest/error';
// import EventEmitter from '@mm-redux/utils/event_emitter';
// import {General} from '@mm-redux/constants';
import {t} from '@utils/i18n';
/* eslint-disable no-throw-literal */
const DEFAULT_TIMEOUT = 10000;
const handleRedirectProtocol = (url, response) => {
const serverUrl = Client4.getUrl();
const parsed = urlParse(url);
const {redirects} = response.rnfbRespInfo;
if (redirects) {
const redirectUrl = urlParse(redirects[redirects.length - 1]);
if (serverUrl === parsed.origin && parsed.host === redirectUrl.host && parsed.protocol !== redirectUrl.protocol) {
Client4.setUrl(serverUrl.replace(parsed.protocol, redirectUrl.protocol));
}
}
};
Client4.doFetchWithResponse = async (url, options) => {
// eslint-disable-next-line no-console
console.log('Request endpoint', url);
const customHeaders = LocalConfig.CustomRequestHeaders;
let requestOptions = {
...Client4.getOptions(options),
};
if (customHeaders && Object.keys(customHeaders).length > 0) {
requestOptions = {
...requestOptions,
headers: {
...requestOptions.headers,
...LocalConfig.CustomRequestHeaders,
},
};
}
let response;
let headers;
let data;
try {
response = await fetch(url, requestOptions);
headers = response.headers;
if (!url.startsWith('https') && response.rnfbRespInfo && response.rnfbRespInfo.redirects && response.rnfbRespInfo.redirects.length > 1) {
handleRedirectProtocol(url, response);
}
data = await response.json();
} catch (err) {
if (response && response.resp && response.resp.data && response.resp.data.includes('SSL certificate')) {
throw new ClientError(Client4.getUrl(), {
message: 'You need to use a valid client certificate in order to connect to this Mattermost server',
status_code: 401,
url,
details: err,
});
}
throw new ClientError(Client4.getUrl(), {
message: 'Received invalid response from the server.',
intl: {
id: t('mobile.request.invalid_response'),
defaultMessage: 'Received invalid response from the server.',
},
url,
details: err,
});
}
const clusterId = headers[HEADER_X_CLUSTER_ID] || headers[HEADER_X_CLUSTER_ID.toLowerCase()];
if (clusterId && Client4.clusterId !== clusterId) {
Client4.clusterId = clusterId; /* eslint-disable-line require-atomic-updates */
}
const token = headers[HEADER_TOKEN] || headers[HEADER_TOKEN.toLowerCase()];
if (token) {
Client4.setToken(token);
}
const serverVersion = headers[HEADER_X_VERSION_ID] || headers[HEADER_X_VERSION_ID.toLowerCase()];
if (serverVersion && !headers['Cache-Control'] && Client4.serverVersion !== serverVersion) {
Client4.serverVersion = serverVersion; /* eslint-disable-line require-atomic-updates */
// EventEmitter.emit(General.SERVER_VERSION_CHANGED, serverVersion);
}
if (response.ok) {
const headersMap = new Map();
Object.keys(headers).forEach((key) => {
headersMap.set(key, headers[key]);
});
return {
response,
headers: headersMap,
data,
};
}
const msg = data.message || '';
if (Client4.logToConsole) {
console.error(msg); // eslint-disable-line no-console
}
throw new ClientError(Client4.getUrl(), {
message: msg,
server_error_id: data.id,
status_code: data.status_code,
url,
});
};
const initFetchConfig = async () => {
const fetchConfig = {
auto: true,
timeout: DEFAULT_TIMEOUT, // Set the base timeout for every request to 5s
};
const userAgent = await DeviceInfo.getUserAgent();
Client4.setUserAgent(userAgent);
window.fetch = new RNFetchBlob.polyfill.Fetch(fetchConfig).build();
return true;
};
initFetchConfig();
export default initFetchConfig;

View file

@ -10,14 +10,15 @@ import LocalConfig from '@assets/config.json';
import {Navigation} from '@constants';
import {DEFAULT_LOCALE, getTranslations, resetMomentLocale, t} from '@i18n';
import * as analytics from '@init/analytics';
import {getAppCredentials, removeAppCredentials} from '@init/credentials';
import {getServerCredentials, removeServerCredentials} from '@init/credentials';
import {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch';
import PushNotifications from '@init/push_notifications';
import {LaunchType} from '@typings/launch';
import {deleteFileCache} from '@utils/file';
type LinkingCallbackArg = {url: string};
class GlobalEventHandler {
launchApp: launchAppFunc | undefined;
JavascriptAndNativeErrorHandler: jsAndNativeErrorHandler | undefined;
constructor() {
@ -29,9 +30,7 @@ class GlobalEventHandler {
Linking.addEventListener('url', this.onDeepLink);
}
configure = (opts: GlobalEventHandlerOpts) => {
this.launchApp = opts.launchApp;
init = () => {
this.JavascriptAndNativeErrorHandler = require('@utils/error_handling').default;
this.JavascriptAndNativeErrorHandler?.initializeErrorHandling();
};
@ -48,11 +47,9 @@ class GlobalEventHandler {
};
onDeepLink = (event: LinkingCallbackArg) => {
const {url} = event;
if (url) {
// TODO: Handle deeplink
// if server is not added go to add new server screen
// if server is added but no credentials found take to server login screen
if (event.url) {
const props = getLaunchPropsFromDeepLink(event.url);
relaunchApp(props);
}
};
@ -125,8 +122,9 @@ class GlobalEventHandler {
analytics.invalidate(serverUrl);
}
// TODO: remove credentials and files for the server
removeAppCredentials();
removeServerCredentials(serverUrl);
// TODO: remove files for the server
deleteFileCache();
PushNotifications.clearNotifications();
@ -136,11 +134,7 @@ class GlobalEventHandler {
await this.clearCookiesAndWebData();
if (this.launchApp) {
this.launchApp(true);
}
// TODO: Remove the database
relaunchApp({launchType: LaunchType.Normal});
};
onServerConfigChanged = (serverUrl: string, config: ClientConfig) => {
@ -178,7 +172,7 @@ class GlobalEventHandler {
};
serverUpgradeNeeded = async (serverUrl: string) => {
const credentials = await getAppCredentials();
const credentials = await getServerCredentials(serverUrl);
if (credentials) {
this.onLogout(serverUrl);

146
app/init/launch.ts Normal file
View file

@ -0,0 +1,146 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Linking} from 'react-native';
import {Notifications} from 'react-native-notifications';
import {Screens} from '@constants';
import {getActiveServerCredentials, getServerCredentials} from '@init/credentials';
import {goToScreen, resetToChannel, resetToSelectServer} from '@screens/navigation';
import {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkType, DeepLinkWithData, LaunchProps, LaunchType} from '@typings/launch';
import {parseDeepLink} from '@utils/url';
export const initialLaunch = async () => {
const deepLinkUrl = await Linking.getInitialURL();
if (deepLinkUrl) {
launchAppFromDeepLink(deepLinkUrl);
return;
}
const notification = await Notifications.getInitialNotification();
if (notification) {
launchAppFromNotification(notification);
return;
}
launchApp({launchType: LaunchType.Normal});
};
const launchAppFromDeepLink = (deepLinkUrl: string) => {
const props = getLaunchPropsFromDeepLink(deepLinkUrl);
launchApp(props);
};
const launchAppFromNotification = (notification: NotificationWithData) => {
const props = getLaunchPropsFromNotification(notification);
launchApp(props);
};
const launchApp = async (props: LaunchProps, resetNavigation = true) => {
let serverUrl;
switch (props?.launchType) {
case LaunchType.DeepLink:
if (props.extra?.type !== DeepLinkType.Invalid) {
const extra = props.extra as DeepLinkWithData;
serverUrl = extra.data?.serverUrl;
}
break;
case LaunchType.Notification: {
const extra = props.extra as NotificationWithData;
serverUrl = extra.payload?.server_url;
break;
}
}
const credentials = serverUrl ? await getServerCredentials(serverUrl) : await getActiveServerCredentials();
if (credentials) {
launchToChannel(props, resetNavigation);
return;
}
launchToServer(props, resetNavigation);
};
const launchToChannel = (props: LaunchProps, resetNavigation: Boolean) => {
// TODO: Use LaunchProps to fetch posts for channel and then load user profile, etc...
const passProps = {
skipMetrics: true,
...props,
};
if (resetNavigation) {
// eslint-disable-next-line no-console
console.log('Launch app in Channel screen');
resetToChannel(passProps);
return;
}
const title = '';
goToScreen(Screens.CHANNEL, title, passProps);
};
const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => {
if (resetNavigation) {
resetToSelectServer(props);
return;
}
const title = '';
goToScreen(Screens.SERVER, title, {...props});
};
export const relaunchApp = (props: LaunchProps) => {
launchApp(props, false);
};
export const getLaunchPropsFromDeepLink = (deepLinkUrl: string): LaunchProps => {
const parsed = parseDeepLink(deepLinkUrl);
const launchProps: LaunchProps = {
launchType: LaunchType.DeepLink,
};
switch (parsed.type) {
case DeepLinkType.Invalid:
launchProps.launchError = true;
break;
case DeepLinkType.Channel: {
const parsedData = parsed.data as DeepLinkChannel;
(launchProps.extra as DeepLinkWithData).data = parsedData;
break;
}
case DeepLinkType.DirectMessage: {
const parsedData = parsed.data as DeepLinkDM;
(launchProps.extra as DeepLinkWithData).data = parsedData;
break;
}
case DeepLinkType.GroupMessage: {
const parsedData = parsed.data as DeepLinkGM;
(launchProps.extra as DeepLinkWithData).data = parsedData;
break;
}
case DeepLinkType.Permalink: {
const parsedData = parsed.data as DeepLinkPermalink;
(launchProps.extra as DeepLinkWithData).data = parsedData;
break;
}
}
return launchProps;
};
export const getLaunchPropsFromNotification = (notification: NotificationWithData): LaunchProps => {
const {payload} = notification;
const launchProps: LaunchProps = {
launchType: LaunchType.Notification,
};
if (payload?.server_url) {
(launchProps.extra as NotificationWithData) = notification;
} else {
launchProps.launchError = true;
}
return launchProps;
};

190
app/init/managed_app.ts Normal file
View file

@ -0,0 +1,190 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm, {ManagedConfig} from '@mattermost/react-native-emm';
import {Alert, AlertButton, AppState, AppStateStatus, Platform} from 'react-native';
import JailMonkey from 'jail-monkey';
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n';
import {getIOSAppGroupDetails} from '@utils/mattermost_managed';
const PROMPT_IN_APP_PIN_CODE_AFTER = 5 * 1000;
class ManagedApp {
backgroundSince = 0;
enabled = false;
inAppPinCode = false;
performingAuthentication = false;
previousAppState?: AppStateStatus;
processConfigTimeout?: NodeJS.Timeout;
vendor = 'Mattermost';
constructor() {
Emm.addListener(this.processConfig);
this.setIOSAppGroupIdentifier();
AppState.addEventListener('change', this.onAppStateChange);
}
init() {
Emm.getManagedConfig().then(this.processConfig);
}
setIOSAppGroupIdentifier = () => {
if (Platform.OS === 'ios') {
const {appGroupIdentifier} = getIOSAppGroupDetails();
if (appGroupIdentifier) {
Emm.setAppGroupId(appGroupIdentifier);
}
}
}
processConfig = async (config?: ManagedConfig) => {
// If the managed configuration changed while authentication was
// being performed, delay the processing of this new configuration
// until authentication is complete.
if (this.performingAuthentication) {
if (this.processConfigTimeout) {
clearTimeout(this.processConfigTimeout);
}
this.processConfigTimeout = setTimeout(() => this.processConfig(config), 500);
}
this.enabled = Boolean(config && Object.keys(config).length);
if (!this.enabled) {
return;
}
const blurScreen = config!.blurApplicationScreen === 'true';
Emm.enableBlurScreen(blurScreen);
const vendor = config!.vendor;
if (vendor) {
this.vendor = vendor;
}
const jailbreakProtection = config!.jailbreakProtection === 'true';
if (jailbreakProtection && !this.isTrustedDevice()) {
this.alertDeviceIsUntrusted();
return;
}
const inAppPinCode = config!.inAppPinCode === 'true';
if (inAppPinCode) {
this.handleDeviceAuthentication();
}
};
alertDeviceIsUntrusted = () => {
const locale = DEFAULT_LOCALE; // TODO: Get current user or system locale
const translations = getTranslations(locale);
Alert.alert(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', this.vendor),
translations[t('mobile.managed.jailbreak')].replace('{vendor}', this.vendor),
[{
text: translations[t('mobile.managed.exit')],
style: 'destructive',
onPress: () => {
Emm.exitApp();
},
}],
{cancelable: false},
);
};
handleDeviceAuthentication = async (authExpired = true) => {
this.performingAuthentication = true;
const isSecured = await Emm.isDeviceSecured();
const locale = DEFAULT_LOCALE;
const translations = getTranslations(locale);
if (!isSecured) {
await this.showNotSecuredAlert(translations);
Emm.exitApp();
return;
}
if (authExpired) {
try {
await Emm.authenticate({
reason: translations[t('mobile.managed.secured_by')].replace('{vendor}', this.vendor),
fallback: true,
supressEnterPassword: true,
});
} catch (err) {
Emm.exitApp();
return;
}
}
this.performingAuthentication = false;
};
isTrustedDevice = () => {
return __DEV__ || JailMonkey.trustFall();
};
onAppStateChange = async (appState: AppStateStatus) => {
const isActive = appState === 'active';
const isBackground = appState === 'background';
if (isActive && this.previousAppState === 'background') {
if (this.enabled && this.inAppPinCode) {
const authExpired = this.backgroundSince > 0 && (Date.now() - this.backgroundSince) >= PROMPT_IN_APP_PIN_CODE_AFTER;
await this.handleDeviceAuthentication(authExpired);
}
this.backgroundSince = 0;
} else if (isBackground) {
this.backgroundSince = Date.now();
}
this.previousAppState = appState;
};
showNotSecuredAlert = (translations: Record<string, string>) => {
return new Promise(async (resolve) => { /* eslint-disable-line no-async-promise-executor */
const buttons: AlertButton[] = [];
if (Platform.OS === 'android') {
buttons.push({
text: translations[t('mobile.managed.settings')],
onPress: () => {
Emm.openSecuritySettings();
},
});
}
buttons.push({
text: translations[t('mobile.managed.exit')],
onPress: resolve,
style: 'cancel',
});
let message;
if (Platform.OS === 'ios') {
const {face} = await Emm.deviceSecureWith();
if (face) {
message = translations[t('mobile.managed.not_secured.ios')];
} else {
message = translations[t('mobile.managed.not_secured.ios.touchId')];
}
} else {
message = translations[t('mobile.managed.not_secured.android')];
}
Alert.alert(
translations[t('mobile.managed.blocked_by')].replace('{vendor}', this.vendor),
message,
buttons,
{cancelable: false, onDismiss: () => resolve},
);
});
};
}
export default new ManagedApp();

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import {getActiveServerDatabase} from '@utils/database';
import {AppState, AppStateStatus, DeviceEventEmitter, EmitterSubscription, Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {
@ -14,11 +16,12 @@ import {
Registered,
} from 'react-native-notifications';
import {dismissAllModals, popToRoot, showOverlay} from '@screens/navigation';
import {Device, Navigation, View} from '@constants';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import EphemeralStore from '@store/ephemeral_store';
import Operator from '@database/operator';
import {getLaunchPropsFromNotification, relaunchApp} from '@init/launch';
import NativeNotifications from '@notifications';
import {showOverlay} from '@screens/navigation';
import {IsolatedEntities} from '@typings/database/enums';
const CATEGORY = 'CAN_REPLY';
const REPLY_ACTION = 'REPLY_ACTION';
@ -28,6 +31,8 @@ const NOTIFICATION_TYPE = {
SESSION: 'session',
};
//todo: Do we need Ephemeral store? Should we refactor this file ?
class PushNotifications {
configured = false;
pushNotificationListener: EmitterSubscription | undefined;
@ -39,8 +44,6 @@ class PushNotifications {
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground);
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground);
AppState.addEventListener('change', this.onAppStateChange);
this.getInitialNotification();
}
cancelAllLocalNotifications = () => {
@ -57,9 +60,14 @@ class PushNotifications {
clearChannelNotifications = async (channelId: string) => {
const notifications = await NativeNotifications.getDeliveredNotifications();
if (Platform.OS === 'android') {
const notificationForChannel = notifications.find((n: NotificationWithChannel) => n.channel_id === channelId);
const notificationForChannel = notifications.find(
(n: NotificationWithChannel) => n.channel_id === channelId,
);
if (notificationForChannel) {
NativeNotifications.removeDeliveredNotifications(notificationForChannel.identifier, channelId);
NativeNotifications.removeDeliveredNotifications(
notificationForChannel.identifier,
channelId,
);
}
} else {
const ids: string[] = [];
@ -67,8 +75,7 @@ class PushNotifications {
// TODO: Set the badgeCount with default database mention count aggregate
for (let i = 0; i < notifications.length; i++) {
const notification = notifications[i];
for (const notification of notifications) {
if (notification.channel_id === channelId) {
ids.push(notification.identifier);
}
@ -88,24 +95,11 @@ class PushNotifications {
const replyTitle = getLocalizedMessage(locale, t('mobile.push_notification_reply.title'));
const replyButton = getLocalizedMessage(locale, t('mobile.push_notification_reply.button'));
const replyPlaceholder = getLocalizedMessage(locale, t('mobile.push_notification_reply.placeholder'));
const replyTextInput: NotificationTextInput = {
buttonTitle: replyButton,
placeholder: replyPlaceholder,
};
const replyTextInput: NotificationTextInput = {buttonTitle: replyButton, placeholder: replyPlaceholder};
const replyAction = new NotificationAction(REPLY_ACTION, 'background', replyTitle, true, replyTextInput);
return new NotificationCategory(CATEGORY, [replyAction]);
};
getInitialNotification = async () => {
const notification: NotificationWithData | undefined = await Notifications.getInitialNotification();
if (notification) {
EphemeralStore.setStartFromNotification(true);
notification.userInteraction = true;
this.handleNotification(notification);
}
};
handleNotification = async (notification: NotificationWithData) => {
const {payload, foreground, userInteraction} = notification;
@ -120,15 +114,8 @@ class PushNotifications {
if (foreground) {
// Show the in-app notification
} else if (userInteraction && !payload.userInfo?.local) {
// Swith to the server / team / channel that matches the notification
const componentId = EphemeralStore.getNavigationTopComponentId();
if (componentId) {
// Emit events to close the sidebars
await dismissAllModals();
await popToRoot();
}
const props = getLaunchPropsFromNotification(notification);
relaunchApp(props);
}
break;
case NOTIFICATION_TYPE.SESSION:
@ -157,7 +144,7 @@ class PushNotifications {
DeviceEventEmitter.emit(Navigation.NAVIGATION_SHOW_OVERLAY);
showOverlay(screen, passProps);
}
}
};
localNotification = (notification: Notification) => {
Notifications.postLocalNotification(notification);
@ -169,7 +156,10 @@ class PushNotifications {
if (isActive) {
if (!this.pushNotificationListener) {
this.pushNotificationListener = DeviceEventEmitter.addListener(View.NOTIFICATION_IN_APP, this.handleInAppNotification);
this.pushNotificationListener = DeviceEventEmitter.addListener(
View.NOTIFICATION_IN_APP,
this.handleInAppNotification,
);
}
} else if (isBackground) {
this.pushNotificationListener?.remove();
@ -194,7 +184,7 @@ class PushNotifications {
this.handleNotification(notification);
};
onRemoteNotificationsRegistered = (event: Registered) => {
onRemoteNotificationsRegistered = async (event: Registered) => {
if (!this.configured) {
this.configured = true;
const {deviceToken} = event;
@ -209,11 +199,24 @@ class PushNotifications {
prefix = Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE;
}
EphemeralStore.deviceToken = `${prefix}:${deviceToken}`;
const {activeServerDatabase, error} = await getActiveServerDatabase();
if (!activeServerDatabase) {
return {error};
}
const operator = new Operator(activeServerDatabase);
operator.handleIsolatedEntity({
tableName: IsolatedEntities.GLOBAL,
values: [{name: 'deviceToken', value: `${prefix}:${deviceToken}`}],
prepareRecordsOnly: false,
});
// Store the device token in the default database
this.requestNotificationReplyPermissions();
}
return null;
};
requestNotificationReplyPermissions = () => {

View file

@ -1,98 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter, Linking} from 'react-native';
import {ComponentDidAppearEvent, ComponentDidDisappearEvent, Navigation} from 'react-native-navigation';
import {Navigation as NavigationConstants, Screens} from '@constants';
import {getAppCredentials} from '@init/credentials';
import emmProvider from '@init/emm_provider';
import globalEventHandler from '@init/global_event_handler';
import {registerScreens} from '@screens/index';
import {resetToChannel, resetToSelectServer} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
const init = () => {
if (EphemeralStore.appStarted) {
launchApp(true);
return;
}
globalEventHandler.configure({
launchApp,
});
registerScreens();
if (!EphemeralStore.appStarted) {
launchApp(false);
}
};
const launchApp = async (skipEmm: boolean) => {
if (!skipEmm) {
await emmProvider.handleManagedConfig();
if (emmProvider.enabled) {
if (emmProvider.jailbreakProtection) {
emmProvider.checkIfDeviceIsTrusted();
}
if (emmProvider.inAppPinCode) {
await emmProvider.handleAuthentication();
}
}
}
Linking.getInitialURL().then((url) => {
if (url) {
// TODO: Handle deeplink
// if server is not added go to add new server screen
// if server is added but no credentials found take to server login screen
}
});
const credentials = await getAppCredentials();
if (credentials) {
// TODO: Fetch posts for current channel and then load user profile, etc..
// eslint-disable-next-line no-console
console.log('Launch app in Channel screen');
resetToChannel({skipMetrics: true});
} else {
resetToSelectServer(emmProvider.allowOtherServers);
}
EphemeralStore.appStarted = true;
};
Navigation.events().registerAppLaunchedListener(() => {
init();
// Keep track of the latest componentId to appear/disappear
Navigation.events().registerComponentDidAppearListener(componentDidAppearListener);
Navigation.events().registerComponentDidDisappearListener(componentDidDisappearListener);
});
export function componentDidAppearListener({componentId}: ComponentDidAppearEvent) {
EphemeralStore.addNavigationComponentId(componentId);
switch (componentId) {
case 'MainSidebar':
DeviceEventEmitter.emit(NavigationConstants.MAIN_SIDEBAR_DID_OPEN, this.handleSidebarDidOpen);
DeviceEventEmitter.emit(NavigationConstants.BLUR_POST_DRAFT);
break;
case 'SettingsSidebar':
DeviceEventEmitter.emit(NavigationConstants.BLUR_POST_DRAFT);
break;
}
}
export function componentDidDisappearListener({componentId}: ComponentDidDisappearEvent) {
if (componentId !== Screens.CHANNEL) {
EphemeralStore.removeNavigationComponentId(componentId);
}
if (componentId === 'MainSidebar') {
DeviceEventEmitter.emit(NavigationConstants.MAIN_SIDEBAR_DID_CLOSE);
}
}

13
app/queries/global.ts Normal file
View file

@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MM_TABLES} from '@constants/database';
import {Database, Q} from '@nozbe/watermelondb';
import Global from '@typings/database/global';
const {DEFAULT: {GLOBAL}} = MM_TABLES;
export const getDeviceToken = async (defaultDatabase: Database) => {
const tokens = (await defaultDatabase.collections.get(GLOBAL).query(Q.where('name', 'deviceToken')).fetch()) as Global[];
return tokens?.[0]?.value ?? '';
};

14
app/queries/role.ts Normal file
View file

@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import Role from '@typings/database/role';
const {SERVER: {ROLE}} = MM_TABLES;
export const getRoles = async (database: Database) => {
const roles = await database.collections.get(ROLE).query().fetch() as Role[];
return roles;
};

38
app/queries/system.ts Normal file
View file

@ -0,0 +1,38 @@
// 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 System from '@typings/database/system';
const {SERVER: {SYSTEM}} = MM_TABLES;
export const getCurrentUserId = async (serverDatabase: Database) => {
const currentUserId = await serverDatabase.collections.get(SYSTEM).query(Q.where('name', 'currentUserId')).fetch() as System[];
return currentUserId?.[0] ?? '';
};
export const getCommonSystemValues = async (database: Database) => {
const systemRecords = (await database.collections.get(SYSTEM).query(Q.where('name', Q.oneOf(['config', 'license', 'currentUserId']))).fetch()) as System[];
let config = {};
let license = {};
let currentUserId = '';
systemRecords.forEach((systemRecord) => {
if (systemRecord.name === 'config') {
config = systemRecord.value;
}
if (systemRecord.name === 'license') {
license = systemRecord.value;
}
if (systemRecord.name === 'currentUserId') {
currentUserId = systemRecord.value;
}
});
return {
currentUserId,
config,
license,
};
};

12
app/queries/user.ts Normal file
View file

@ -0,0 +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 User from '@typings/database/user';
export const getUserById = async ({userId, database}: { userId: string, database: Database}) => {
const userRecords = (await database.collections.get(MM_TABLES.SERVER.USER).query(Q.where('id', userId)).fetch()) as User[];
return userRecords?.[0];
};

View file

@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Operator from '@database/operator';
import {ServerUrlChangedArgs} from '@typings/database/database';
import {IsolatedEntities} from '@typings/database/enums';
import System from '@typings/database/system';
import {getActiveServerDatabase} from '@utils/database';
/**
* setLastUpgradeCheck: Takes in 'config' record from System entity and update its lastUpdateCheck to Date.now()
* @param {System} configRecord
* @returns {Promise<void>}
*/
export const setLastUpgradeCheck = async (configRecord: System) => {
const {activeServerDatabase: database, error} = await getActiveServerDatabase();
if (!database) {
return {error};
}
await database.action(async () => {
await configRecord.update((config) => {
config.value = {...configRecord.value, lastUpdateCheck: Date.now()};
});
});
return null;
};
export const handleServerUrlChanged = async ({configRecord, licenseRecord, selectServerRecord, serverUrl}: ServerUrlChangedArgs) => {
const {activeServerDatabase: database, error} = await getActiveServerDatabase();
if (!database) {
return {error};
}
await database.action(async () => {
await database.batch(
...[
configRecord.prepareUpdate((config: System) => {
config.value = {};
}),
licenseRecord.prepareUpdate((license: System) => {
license.value = {};
}),
selectServerRecord.prepareUpdate((server: System) => {
server.value = {...server.value, serverUrl};
}),
],
);
});
return null;
};
export const createSessions = async (sessions: any) => {
const {activeServerDatabase: database, error} = await getActiveServerDatabase();
if (!database) {
return {error};
}
const operator = new Operator(database);
await operator.handleIsolatedEntity({
tableName: IsolatedEntities.SYSTEM,
values: [{
// id: string; // todo: to confirm value for session id ?
name: 'sessions',
value: sessions,
}],
prepareRecordsOnly: false,
});
return null;
};
export const setDeepLinkUrl = async (url: string) => {
const operator = new Operator();
await operator.handleIsolatedEntity({
tableName: IsolatedEntities.GLOBAL,
values: [{
name: 'deepLinkUrl',
value: url,
}],
prepareRecordsOnly: false,
});
};

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getTimeZone} from 'react-native-localize';
import {getUserById} from '@queries/user';
import {updateMe} from '@requests/remote/user';
import {Config} from '@typings/database/config';
import User from '@typings/database/user';
import {getActiveServerDatabase} from '@utils/database';
export const isTimezoneEnabled = (config: Partial<Config>) => {
return config?.ExperimentalTimezone === 'true';
};
export function getDeviceTimezone() {
return getTimeZone();
}
export const autoUpdateTimezone = async ({deviceTimezone, userId}: {deviceTimezone: string, userId: string}) => {
const {activeServerDatabase, error} = await getActiveServerDatabase();
if (!activeServerDatabase) {
return {error};
}
const currentUser = await getUserById({userId, database: activeServerDatabase}) ?? null;
if (!currentUser) {
return null;
}
const currentTimezone = getUserTimezone(currentUser);
const newTimezoneExists = currentTimezone.automaticTimezone !== deviceTimezone;
if (currentTimezone.useAutomaticTimezone && newTimezoneExists) {
const timezone = {useAutomaticTimezone: 'true', automaticTimezone: deviceTimezone, manualTimezone: currentTimezone.manualTimezone};
const updatedUser = {...currentUser, timezone} as User;
await updateMe(updatedUser);
}
return null;
};
export const getUserTimezone = (currentUser: User) => {
if (currentUser?.timezone) {
return {
...currentUser?.timezone,
useAutomaticTimezone: currentUser?.timezone?.useAutomaticTimezone === 'true',
};
}
return {
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
};
};

View file

@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//fixme: to be completed in next PR.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const logError = (_e: any) => null;
// export const logError = async (error: Error, displayable = false) => {
// if (error?.server_error_id === 'api.context.session_expired.app_error') {
// return {data: true};
// }
//
// const serializedError = serializeError(error);
//
// let sendToServer = true;
// if (error.stack && error.stack.includes('TypeError: Failed to fetch')) {
// sendToServer = false;
// }
// if (error?.server_error_id) {
// sendToServer = false;
// }
//
// if (sendToServer) {
// try {
// const stringifiedSerializedError = JSON.stringify(serializedError).toString();
// await Client4.logClientError(stringifiedSerializedError);
// } catch (err) {
// // avoid crashing the app if an error sending
// // the error occurs.
// }
// }
//
// EventEmitter.emit(ErrorTypes.LOG_ERROR, error);
// dispatch(getLogErrorAction(serializedError, displayable));
// return {data: true};
// };

View file

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@client/rest';
export const doPing = async (serverUrl?: string) => {
let data;
const pingError = {
id: 'mobile.server_ping_failed',
defaultMessage: 'Cannot connect to the server. Please check your server URL and internet connection.',
};
try {
if (serverUrl) {
Client4.setUrl(serverUrl);
}
data = await Client4.ping();
if (data.status !== 'OK') {
// successful ping but not the right return {data}
return {error: {intl: pingError}};
}
} catch (error) {
// Client4Error
if (error.status_code === 401) {
// When the server requires a client certificate to connect.
return {error};
}
return {error: {intl: pingError}};
}
return {data};
};
export const fetchConfigAndLicense = async () => {
try {
const [config, license] = await Promise.all<ClientConfig, ClientLicense>([
Client4.getClientConfigOld(),
Client4.getClientLicenseOld(),
]);
return {config, license};
} catch (error) {
return {error};
}
};

View file

@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {IntlShape} from 'react-intl';
import {Client4} from '@client/rest';
import PushNotifications from '@init/push_notifications';
import {getCommonSystemValues} from '@queries/system';
import {getSessions} from '@requests/remote/user';
import {Config} from '@typings/database/config';
import {getActiveServerDatabase} from '@utils/database';
import {isMinimumServerVersion} from '@utils/helpers';
const MAJOR_VERSION = 5;
const MINOR_VERSION = 24;
const sortByNewest = (a: any, b: any) => {
if (a.create_at > b.create_at) {
return -1;
}
return 1;
};
export const scheduleExpiredNotification = async (intl: IntlShape) => {
const {activeServerDatabase: database, error} = await getActiveServerDatabase();
if (!database) {
return {error};
}
const {currentUserId, config}: {currentUserId: string, config: Partial<Config>} = await getCommonSystemValues(database);
if (isMinimumServerVersion(Client4.serverVersion, MAJOR_VERSION, MINOR_VERSION) && config.ExtendSessionLengthWithActivity === 'true') {
PushNotifications.cancelAllLocalNotifications();
return null;
}
const timeOut = setTimeout(async () => {
clearTimeout(timeOut);
let sessions: any;
try {
sessions = await getSessions(currentUserId);
} catch (e) {
// console.warn('Failed to get current session', e);
return;
}
if (!Array.isArray(sessions?.data)) {
return;
}
const session = sessions.data.sort(sortByNewest)[0];
const expiresAt = session?.expires_at || 0; //eslint-disable-line camelcase
const expiresInDays = parseInt(String(Math.ceil(Math.abs(moment.duration(moment().diff(expiresAt)).asDays()))), 10);
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,
},
);
if (expiresAt) {
PushNotifications.scheduleNotification({
fireDate: expiresAt,
body: message,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
userInfo: {
local: true,
},
});
}
}, 20000);
return null;
};

View file

@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@client/rest';
import Operator from '@database/operator';
import {getRoles} from '@queries/role';
import {IsolatedEntities} from '@typings/database/enums';
import {getActiveServerDatabase} from '@utils/database';
export const loadRolesIfNeeded = async (updatedRoles: string[]) => {
const {activeServerDatabase: database, error: e} = await getActiveServerDatabase();
if (!database) {
return {error: e};
}
const existingRoles = ((await getRoles(database)) as unknown) as Role[];
const roleNames = existingRoles.map((role) => {
return role.name;
});
const newRoles = updatedRoles.filter((newRole) => {
return !roleNames.includes(newRole);
});
try {
const operator = new Operator(database);
const data = await Client4.getRolesByNames(newRoles);
await operator.handleIsolatedEntity({
tableName: IsolatedEntities.ROLE,
values: data,
prepareRecordsOnly: false,
});
} catch (error) {
return {error};
}
return null;
};

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@client/rest';
import {logError} from '@requests/remote/error';
import {forceLogoutIfNecessary} from '@requests/remote/user';
export const getDataRetentionPolicy = async () => {
let data = {};
try {
data = await Client4.getDataRetentionPolicy();
} catch (error) {
forceLogoutIfNecessary(error);
//fixme: do we care for the below line ? It seems that the `error` object is never read ??
// dispatch(batchActions([{type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, error,},]));
logError(error);
return {error};
}
return data;
};

386
app/requests/remote/user.ts Normal file
View file

@ -0,0 +1,386 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@client/rest';
import Operator from '@database/operator';
import analytics from '@init/analytics';
import {setServerCredentials} from '@init/credentials';
import {getDeviceToken} from '@queries/global';
import {getCommonSystemValues, getCurrentUserId} from '@queries/system';
import {createSessions} from '@requests/local/systems';
import {autoUpdateTimezone, getDeviceTimezone, isTimezoneEnabled} from '@requests/local/timezone';
import {logError} from '@requests/remote/error';
import {loadRolesIfNeeded} from '@requests/remote/role';
import {getDataRetentionPolicy} from '@requests/remote/systems';
import {Client4Error} from '@typings/api/client4';
import {Config} from '@typings/database/config';
import {
LoadMeArgs,
LoginArgs,
RawMyTeam,
RawPreference,
RawRole,
RawTeam,
RawTeamMembership,
RawUser,
} from '@typings/database/database';
import {IsolatedEntities} from '@typings/database/enums';
import {License} from '@typings/database/license';
import Role from '@typings/database/role';
import User from '@typings/database/user';
import {createAndSetActiveDatabase, getActiveServerDatabase, getDefaultDatabase} from '@utils/database';
import {getCSRFFromCookie} from '@utils/security';
const HTTP_UNAUTHORIZED = 401;
export const logout = async (skipServerLogout = false) => {
return async () => {
if (!skipServerLogout) {
try {
await Client4.logout();
} catch {
// Do nothing
}
}
//fixme: uncomment below EventEmitter.emit
// EventEmitter.emit(NavigationTypes.NAVIGATION_RESET);
return {data: true};
};
};
export const forceLogoutIfNecessary = async (err: Client4Error) => {
const {activeServerDatabase, error} = await getActiveServerDatabase();
if (!activeServerDatabase) {
return {error};
}
const currentUserId = await getCurrentUserId(activeServerDatabase);
if ('status_code' in err && err.status_code === HTTP_UNAUTHORIZED && err?.url?.indexOf('/login') === -1 && currentUserId) {
await logout(false);
}
return {error: null};
};
export const login = async ({ldapOnly = false, loginId, mfaToken, password}: LoginArgs) => {
let deviceToken;
let user;
const {error, defaultDatabase} = await getDefaultDatabase();
if (!defaultDatabase) {
return {error};
}
const url = Client4.getUrl();
try {
deviceToken = await getDeviceToken(defaultDatabase);
user = ((await Client4.login(
loginId,
password,
mfaToken,
deviceToken,
ldapOnly,
)) as unknown) as RawUser;
await createAndSetActiveDatabase({serverUrl: url});
await getCSRFFromCookie(Client4.getUrl());
} catch (e) {
return {error: e};
}
const result = await loadMe({user, deviceToken});
if (!result?.error) {
await completeLogin(user);
}
return result;
};
export const loadMe = async ({deviceToken, user}: LoadMeArgs) => {
let currentUser = user;
const {activeServerDatabase, error} = await getActiveServerDatabase();
if (!activeServerDatabase) {
return {
error,
currentUser: undefined,
};
}
try {
if (deviceToken) {
await Client4.attachDevice(deviceToken);
}
if (!currentUser) {
currentUser = ((await Client4.getMe()) as unknown) as RawUser;
}
} catch (e) {
await forceLogoutIfNecessary(e);
return {
error: e,
currentUser: undefined,
};
}
try {
const analyticsClient = analytics.create(Client4.getUrl());
analyticsClient.setUserId(currentUser.id);
analyticsClient.setUserRoles(currentUser.roles);
//todo: Ask for a unified endpoint that will serve all those values in one go.( while ensuring backward-compatibility through fallbacks to previous code-path)
const teamsRequest = Client4.getMyTeams();
// Goes into myTeam table
const teamMembersRequest = Client4.getMyTeamMembers();
const teamUnreadRequest = Client4.getMyTeamUnreads();
const preferencesRequest = Client4.getMyPreferences();
const configRequest = Client4.getClientConfigOld();
const licenseRequest = Client4.getClientLicenseOld();
const [
teams,
teamMembers,
teamUnreads,
preferences,
config,
license,
] = await Promise.all([
teamsRequest,
teamMembersRequest,
teamUnreadRequest,
preferencesRequest,
configRequest,
licenseRequest,
]);
const operator = new Operator(activeServerDatabase);
const teamRecords = operator.handleTeam({prepareRecordsOnly: true, teams: teams as RawTeam[]});
const teamMembershipRecords = operator.handleTeamMemberships({prepareRecordsOnly: true, teamMemberships: (teamMembers as unknown) as RawTeamMembership[]});
const myTeams = teamUnreads.map((unread) => {
const matchingTeam = teamMembers.find((team) => team.team_id === unread.team_id);
return {team_id: unread.team_id, roles: matchingTeam?.roles ?? '', is_unread: unread.msg_count > 0, mentions_count: unread.mention_count};
});
const myTeamRecords = operator.handleMyTeam({
prepareRecordsOnly: true,
myTeams: (myTeams as unknown) as RawMyTeam[],
});
const systemRecords = operator.handleIsolatedEntity({
tableName: IsolatedEntities.SYSTEM,
values: [
{
name: 'config',
value: config,
},
{
name: 'license',
value: license,
},
{
name: 'currentUserId',
value: currentUser.id,
},
{
name: 'url',
value: Client4.getUrl(),
},
],
prepareRecordsOnly: true,
});
const userRecords = operator.handleUsers({
users: [currentUser],
prepareRecordsOnly: true,
});
const preferenceRecords = operator.handlePreferences({
prepareRecordsOnly: true,
preferences: (preferences as unknown) as RawPreference[],
});
let roles: string[] = [];
for (const role of currentUser.roles.split(' ')) {
roles = roles.concat(role);
}
for (const teamMember of teamMembers) {
roles = roles.concat(teamMember.roles.split(' '));
}
const rolesToLoad = new Set<string>(roles);
let rolesRecords: Role[] = [];
if (rolesToLoad.size > 0) {
const rolesByName = ((await Client4.getRolesByNames(Array.from(rolesToLoad))) as unknown) as RawRole[];
if (rolesByName?.length) {
rolesRecords = operator.handleIsolatedEntity({tableName: IsolatedEntities.ROLE, prepareRecordsOnly: true, values: rolesByName}) as Role[];
}
}
const models = await Promise.all([teamRecords, teamMembershipRecords, myTeamRecords, systemRecords, preferenceRecords, rolesRecords, userRecords]);
const flattenedModels = models.flat();
if (flattenedModels?.length > 0) {
await operator.batchOperations({database: activeServerDatabase, models: flattenedModels});
}
} catch (e) {
return {error: e, currentUser: undefined};
}
return {currentUser, error: undefined};
};
export const completeLogin = async (user: RawUser) => {
const {activeServerDatabase, error} = await getActiveServerDatabase();
if (!activeServerDatabase) {
return {error};
}
const {config, license}: { config: Partial<Config>; license: Partial<License>; } = await getCommonSystemValues(activeServerDatabase);
if (!Object.keys(config)?.length || !Object.keys(license)?.length) {
return null;
}
const token = Client4.getToken();
const serverUrl = Client4.getUrl();
setServerCredentials(serverUrl, user.id, token);
// Set timezone
if (isTimezoneEnabled(config)) {
const timezone = getDeviceTimezone();
await autoUpdateTimezone({deviceTimezone: timezone, userId: user.id});
}
let dataRetentionPolicy: any;
const operator = new Operator(activeServerDatabase);
// Data retention
if (config?.DataRetentionEnableMessageDeletion === 'true' && license?.IsLicensed === 'true' && license?.DataRetention === 'true') {
dataRetentionPolicy = await getDataRetentionPolicy();
await operator.handleIsolatedEntity({tableName: IsolatedEntities.SYSTEM, values: [{name: 'dataRetentionPolicy', value: dataRetentionPolicy}], prepareRecordsOnly: false});
}
return null;
};
export const updateMe = async (user: User) => {
const {activeServerDatabase, error} = await getActiveServerDatabase();
if (!activeServerDatabase) {
return {error};
}
let data;
try {
data = ((await Client4.patchMe(user._raw)) as unknown) as RawUser;
} catch (e) {
logError(e);
return {error: e};
}
const operator = new Operator(activeServerDatabase);
const systemRecords = operator.handleIsolatedEntity({
tableName: IsolatedEntities.SYSTEM,
values: [
{name: 'currentUserId', value: data.id},
{name: 'locale', value: data?.locale},
],
prepareRecordsOnly: true,
});
const userRecord = operator.handleUsers({prepareRecordsOnly: true, users: [data]});
//todo: ?? Do we need to write to TOS entity ? See app/mm-redux/reducers/entities/users.ts/profiles/line 152 const
// tosRecords = await DataOperator.handleIsolatedEntity({ tableName: TERMS_OF_SERVICE, values: [{}], });
const models = await Promise.all([
...systemRecords,
...userRecord,
// ...tosRecords,
]);
if (models?.length) {
await operator.batchOperations({database: activeServerDatabase, models: models.flat()});
}
const updatedRoles: string[] = data.roles.split(' ');
if (updatedRoles.length) {
await loadRolesIfNeeded(updatedRoles);
}
return {data};
};
export const getSessions = async (currentUserId: string) => {
try {
const sessions = await Client4.getSessions(currentUserId);
await createSessions(sessions);
} catch (e) {
logError(e);
await forceLogoutIfNecessary(e);
}
};
type LoadedUser = {
currentUser?: RawUser,
error?: Client4Error
}
export const ssoLogin = async (serverUrl: string) => {
let deviceToken;
const {error, defaultDatabase} = await getDefaultDatabase();
if (!defaultDatabase) {
return {error};
}
// Setting up active database for this SSO login flow
try {
await createAndSetActiveDatabase({serverUrl});
deviceToken = await getDeviceToken(defaultDatabase);
} catch (e) {
return {error: e};
}
let result;
try {
result = await loadMe({deviceToken}) as unknown as LoadedUser;
if (!result?.error && result?.currentUser) {
await completeLogin(result.currentUser);
}
} catch (e) {
return {error: e};
}
return result;
};
export const sendPasswordResetEmail = async (email: string) => {
let data;
try {
data = await Client4.sendPasswordResetEmail(email);
} catch (e) {
return {
error: e,
};
}
return {
data,
error: undefined,
};
};

View file

@ -11,9 +11,27 @@ import {
ReloadInstructions,
} from 'react-native/Libraries/NewAppScreen';
import type {LaunchProps} from '@typings/launch';
declare const global: {HermesInternal: null | {}};
const Channel = () => {
type ChannelProps = LaunchProps
const Channel = (props: ChannelProps) => {
// TODO: If we have LaunchProps, ensure we load the correct channel/post/modal.
const {launchType} = props;
console.log(launchType); // eslint-disable-line no-console
// TODO: If LaunchProps.error is true, use the LaunchProps.launchType to determine which
// error message to display. For example:
// if (props.launchError) {
// let erroMessage;
// if (props.launchType === LaunchType.DeepLink) {
// errorMessage = intl.formatMessage({id: 'mobile.launchError.deepLink', defaultMessage: 'Did not find a server for this deep link'});
// } else if (props.launchType === LaunchType.Notification) {
// errorMessage = intl.formatMessage({id: 'mobile.launchError.notification', defaultMessage: 'Did not find a server for this notification'});
// }
// }
return (
<>
<StatusBar barStyle='dark-content'/>

View file

@ -0,0 +1,152 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ForgotPassword should match snapshot 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
testID="forgot.password.screen"
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"flexDirection": "column",
"justifyContent": "center",
"paddingHorizontal": 15,
"paddingVertical": 50,
}
}
>
<Image
source={
Object {
"testUri": "../../../dist/assets/images/logo.png",
}
}
style={
Object {
"height": 72,
"resizeMode": "contain",
}
}
/>
<Text
style={
Array [
Object {
"color": "#fd5960",
"fontSize": 12,
"marginBottom": 15,
"marginTop": 15,
"textAlign": "left",
},
undefined,
]
}
testID="forgot.password.error.text"
>
</Text>
<View
testID="password_send.link.prepare"
>
<Text
style={
Array [
Object {
"color": "rgba(61,60,64,0.6)",
"fontSize": 16,
"fontWeight": "300",
"lineHeight": 22,
"marginBottom": 15,
"textAlign": "center",
},
Object {
"paddingTop": 15,
},
]
}
>
To reset your password, enter the email address you used to sign up
</Text>
<TextInput
allowFontScaling={true}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
disableFullscreenUI={true}
keyboardType="email-address"
onChangeText={[Function]}
placeholder="Email"
placeholderTextColor="rgba(61,60,64,0.5)"
rejectResponderTermination={true}
style={
Object {
"alignSelf": "stretch",
"borderColor": "gainsboro",
"borderRadius": 3,
"borderWidth": 1,
"color": "#3d3c40",
"fontSize": 16,
"height": 45,
"marginBottom": 5,
"marginTop": 5,
"paddingLeft": 10,
}
}
testID="forgot.password.email"
underlineColorAndroid="transparent"
/>
<View
accessibilityRole="button"
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"alignSelf": "stretch",
"borderColor": "#166de0",
"borderRadius": 3,
"borderWidth": 1,
"marginTop": 10,
"opacity": 1,
"padding": 15,
}
}
testID="forgot.password.button"
>
<Text
style={
Object {
"color": "#166de0",
"fontSize": 17,
"textAlign": "center",
}
}
>
Reset my password
</Text>
</View>
</View>
</View>
</RNCSafeAreaView>
`;

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, waitFor} from '@testing-library/react-native';
import React from 'react';
import {Preferences} from '@constants';
import * as UserAPICalls from '@requests/remote/user';
import {renderWithIntl, fireEvent} from '@test/intl-test-helper';
import ForgotPassword from './index';
describe('ForgotPassword', () => {
const baseProps = {
componentId: 'ForgotPassword',
theme: Preferences.THEMES.default,
};
test('should match snapshot', () => {
const {toJSON} = renderWithIntl(<ForgotPassword {...baseProps}/>);
expect(toJSON()).toMatchSnapshot();
});
test('Error on failure of email regex', async () => {
const {getByTestId} = renderWithIntl(<ForgotPassword {...baseProps}/>);
const emailTextInput = getByTestId('forgot.password.email');
const resetButton = getByTestId('forgot.password.button');
fireEvent.changeText(emailTextInput, 'bar');
act(() => {
fireEvent.press(resetButton);
});
expect(getByTestId('forgot.password.error.text')).toBeDefined();
});
test('Should show password link sent texts', async () => {
const spyOnResetAPICall = jest.spyOn(UserAPICalls, 'sendPasswordResetEmail');
const {getByTestId} = renderWithIntl(<ForgotPassword {...baseProps}/>);
const emailTextInput = getByTestId('forgot.password.email');
const resetButton = getByTestId('forgot.password.button');
fireEvent.changeText(emailTextInput, 'test@test.com');
await waitFor(() => {
fireEvent.press(resetButton);
});
expect(spyOnResetAPICall).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,223 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Image, Text, TextInput, TouchableWithoutFeedback, View} from 'react-native';
import Button from 'react-native-button';
import {SafeAreaView} from 'react-native-safe-area-context';
import ErrorText from '@components/error_text';
import FormattedText from '@components/formatted_text';
import {sendPasswordResetEmail} from '@requests/remote/user';
import {isEmail} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
theme: Theme;
}
const ForgotPassword = ({theme}: Props) => {
const [email, setEmail] = useState<string>('');
const [error, setError] = useState<string>('');
const [isPasswordLinkSent, setIsPasswordLinkSent] = useState<boolean>(false);
const {formatMessage} = useIntl();
const emailIdRef = useRef<TextInput>(null);
const styles = getStyleSheet(theme);
const changeEmail = (emailAddress: string) => {
setEmail(emailAddress);
};
const submitResetPassword = async () => {
if (!isEmail(email)) {
setError(
formatMessage({
id: 'password_send.error',
defaultMessage: 'Please enter a valid email address.',
}),
);
return;
}
const {data, error: apiError = undefined} = await sendPasswordResetEmail(email);
if (data) {
setIsPasswordLinkSent(true);
}
setError(apiError);
};
const onBlur = useCallback(() => {
emailIdRef.current?.blur();
}, []);
const getDisplayErrorView = () => {
return (
<ErrorText
error={error}
testID='forgot.password.error.text'
textStyle={styles.errorText}
theme={theme}
/>
);
};
const getCenterContent = () => {
if (isPasswordLinkSent) {
return (
<View
style={styles.resetSuccessContainer}
testID={'password_send.link.sent'}
>
<FormattedText
style={styles.successTxtColor}
id='password_send.link'
defaultMessage='If the account exists, a password reset email will be sent to:'
/>
<Text style={[styles.successTxtColor, styles.emailId]}>
{email}
</Text>
<FormattedText
style={[
styles.successTxtColor,
styles.defaultTopPadding,
]}
id='password_send.checkInbox'
defaultMessage='Please check your inbox.'
/>
</View>
);
}
return (
<View testID={'password_send.link.prepare'}>
<FormattedText
style={[styles.subheader, styles.defaultTopPadding]}
id='password_send.description'
defaultMessage='To reset your password, enter the email address you used to sign up'
/>
<TextInput
ref={emailIdRef}
style={styles.inputBox}
onChangeText={changeEmail}
placeholder={formatMessage({
id: 'login.email',
defaultMessage: 'Email',
})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
autoCorrect={false}
autoCapitalize='none'
keyboardType='email-address'
underlineColorAndroid='transparent'
blurOnSubmit={false}
disableFullscreenUI={true}
testID={'forgot.password.email'}
/>
<Button
testID='forgot.password.button'
containerStyle={styles.signupButton}
disabled={!email}
onPress={submitResetPassword}
>
<FormattedText
id='password_send.reset'
defaultMessage='Reset my password'
style={styles.signupButtonText}
/>
</Button>
</View>
);
};
return (
<SafeAreaView
testID='forgot.password.screen'
style={styles.container}
>
<TouchableWithoutFeedback onPress={onBlur}>
<View style={styles.innerContainer}>
<Image
source={require('@assets/images/logo.png')}
style={styles.innerContainerImage}
/>
{getDisplayErrorView()}
{getCenterContent()}
</View>
</TouchableWithoutFeedback>
</SafeAreaView>
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
},
innerContainer: {
alignItems: 'center',
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: 15,
paddingVertical: 50,
},
forgotPasswordBtn: {
borderColor: 'transparent',
marginTop: 15,
},
resetSuccessContainer: {
marginTop: 15,
padding: 10,
backgroundColor: '#dff0d8',
borderColor: '#d6e9c6',
},
emailId: {
fontWeight: 'bold',
},
successTxtColor: {
color: '#3c763d',
},
defaultTopPadding: {
paddingTop: 15,
},
subheader: {
textAlign: 'center',
fontSize: 16,
fontWeight: '300',
color: changeOpacity(theme.centerChannelColor, 0.6),
marginBottom: 15,
lineHeight: 22,
},
inputBox: {
fontSize: 16,
height: 45,
borderColor: 'gainsboro',
borderWidth: 1,
marginTop: 5,
marginBottom: 5,
paddingLeft: 10,
alignSelf: 'stretch',
borderRadius: 3,
color: theme.centerChannelColor,
},
signupButton: {
borderRadius: 3,
borderColor: theme.buttonBg,
borderWidth: 1,
alignItems: 'center',
alignSelf: 'stretch',
marginTop: 10,
padding: 15,
},
signupButtonText: {
textAlign: 'center',
color: theme.buttonBg,
fontSize: 17,
},
innerContainerImage: {
height: 72,
resizeMode: 'contain',
},
}));
export default ForgotPassword;

View file

@ -2,18 +2,20 @@
// See LICENSE.txt for license information.
import React from 'react';
import {IntlProvider} from 'react-intl';
import {Navigation, NavigationFunctionComponent} from 'react-native-navigation';
import {Platform, StyleProp, ViewStyle} from 'react-native';
import {withManagedConfig} from '@mattermost/react-native-emm';
import {Navigation} from 'react-native-navigation';
import {gestureHandlerRootHOC} from 'react-native-gesture-handler';
import {withManagedConfig} from '@mattermost/react-native-emm';
import {Screens} from '@constants';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
// TODO: Remove this and uncomment screens as they get added
/* eslint-disable */
const withGestures = (screen: React.ComponentType<any>, styles: StyleProp<ViewStyle>) => {
const withGestures = (screen: NavigationFunctionComponent, styles: StyleProp<ViewStyle>) => {
if (Platform.OS === 'android') {
return gestureHandlerRootHOC(screen, styles);
}
@ -21,10 +23,23 @@ const withGestures = (screen: React.ComponentType<any>, styles: StyleProp<ViewSt
return screen;
};
const withIntl = (Screen: React.ComponentType) => {
return function IntlEnabledComponent(props: any) {
return (
<IntlProvider
locale={DEFAULT_LOCALE}
messages={getTranslations()}
>
<Screen {...props}/>
</IntlProvider>
);
}
}
Navigation.setLazyComponentRegistrator((screenName) => {
// let screen: any;
// let extraStyles: StyleProp<ViewStyle>;
// switch (screenName) {
let screen: any|undefined;
let extraStyles: StyleProp<ViewStyle>;
switch (screenName) {
// case 'About':
// screen = require('@screens/about').default;
// break;
@ -79,30 +94,30 @@ Navigation.setLazyComponentRegistrator((screenName) => {
// case 'FlaggedPosts':
// screen = require('@screens/flagged_posts').default;
// break;
// case 'ForgotPassword':
// screen = require('@screens/forgot_password').default;
// break;
case 'ForgotPassword':
screen = require('@screens/forgot_password').default;
break;
// case 'Gallery':
// screen = require('@screens/gallery').default;
// break;
// case 'InteractiveDialog':
// screen = require('@screens/interactive_dialog').default;
// break;
// case 'Login':
// screen = require('@screens/login').default;
// break;
// case 'LoginOptions':
// screen = require('@screens/login_options').default;
// break;
case 'Login':
screen = require('@screens/login').default;
break;
case 'LoginOptions':
screen = require('@screens/login_options').default;
break;
// case 'LongPost':
// screen = require('@screens/long_post').default;
// break;
// case 'MainSidebar':
// screen = require('app/components/sidebars/main').default;
// break;
// case 'MFA':
// screen = require('@screens/mfa').default;
// break;
case 'MFA':
screen = require('@screens/mfa').default;
break;
// case 'MoreChannels':
// screen = require('@screens/more_channels').default;
// break;
@ -170,9 +185,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
// case 'SidebarSettings':
// screen = require('@screens/settings/sidebar').default;
// break;
// case 'SSO':
// screen = require('@screens/sso').default;
// break;
case 'SSO':
screen = require('@screens/sso').default;
break;
// case 'Table':
// screen = require('@screens/table').default;
// break;
@ -191,11 +206,11 @@ Navigation.setLazyComponentRegistrator((screenName) => {
// case 'UserProfile':
// screen = require('@screens/user_profile').default;
// break;
// }
}
// if (screen) {
// Navigation.registerComponent(screenName, () => withGestures(withManagedConfig(screen), extraStyles));
// }
if (screen) {
Navigation.registerComponent(screenName, () => withGestures(withIntl(withManagedConfig(screen)), extraStyles));
}
});
export function registerScreens() {
@ -203,5 +218,5 @@ export function registerScreens() {
const serverScreen = require('@screens/server').default;
Navigation.registerComponent(Screens.CHANNEL, () => withManagedConfig(channelScreen));
Navigation.registerComponent(Screens.SERVER, () => withManagedConfig(serverScreen));
Navigation.registerComponent(Screens.SERVER, () => withIntl(withManagedConfig(serverScreen)));
}

View file

@ -0,0 +1,213 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Login Login screen should match snapshot 1`] = `
<RCTSafeAreaView
emulateUnlessSupported={true}
style={
Object {
"flex": 1,
}
}
>
<RCTScrollView
accessible={false}
automaticallyAdjustContentInsets={false}
contentContainerStyle={
Object {
"alignItems": "center",
"flexDirection": "column",
"justifyContent": "center",
"paddingHorizontal": 15,
"paddingVertical": 50,
}
}
contentInset={
Object {
"bottom": 0,
}
}
enableAutomaticScroll={true}
enableOnAndroid={true}
enableResetScrollToCoords={true}
extraHeight={75}
extraScrollHeight={0}
focusable={true}
getScrollResponder={[Function]}
handleOnScroll={[Function]}
keyboardDismissMode="interactive"
keyboardOpeningTime={250}
keyboardShouldPersistTaps="handled"
keyboardSpace={0}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onScroll={[Function]}
onStartShouldSetResponder={[Function]}
resetKeyboardSpace={[Function]}
scrollEventThrottle={1}
scrollForExtraHeightOnAndroid={[Function]}
scrollIntoView={[Function]}
scrollToEnd={[Function]}
scrollToFocusedInput={[Function]}
scrollToPosition={[Function]}
showsVerticalScrollIndicator={true}
style={
Object {
"flex": 1,
}
}
update={[Function]}
viewIsInsideTabBar={false}
>
<View>
<Image
source={
Object {
"testUri": "../../../dist/assets/images/logo.png",
}
}
style={
Object {
"height": 72,
"resizeMode": "contain",
}
}
/>
<TextInput
allowFontScaling={true}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
disableFullscreenUI={true}
keyboardType="email-address"
onChangeText={[Function]}
onSubmitEditing={[Function]}
placeholder="Email or Username"
placeholderTextColor="rgba(61,60,64,0.5)"
rejectResponderTermination={true}
returnKeyType="next"
style={
Object {
"alignSelf": "stretch",
"borderColor": "gainsboro",
"borderRadius": 3,
"borderWidth": 1,
"color": "#3d3c40",
"fontSize": 16,
"height": 45,
"marginBottom": 5,
"marginTop": 5,
"paddingLeft": 10,
}
}
testID="login.username.input"
underlineColorAndroid="transparent"
value=""
/>
<TextInput
allowFontScaling={true}
autoCapitalize="none"
autoCorrect={false}
disableFullscreenUI={true}
onChangeText={[Function]}
onSubmitEditing={[Function]}
placeholder="Password"
placeholderTextColor="rgba(61,60,64,0.5)"
rejectResponderTermination={true}
returnKeyType="go"
secureTextEntry={true}
style={
Object {
"alignSelf": "stretch",
"borderColor": "gainsboro",
"borderRadius": 3,
"borderWidth": 1,
"color": "#3d3c40",
"fontSize": 16,
"height": 45,
"marginBottom": 5,
"marginTop": 5,
"paddingLeft": 10,
}
}
testID="login.password.input"
underlineColorAndroid="transparent"
value=""
/>
<View
accessibilityRole="button"
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"alignSelf": "stretch",
"borderColor": "#166de0",
"borderRadius": 3,
"borderWidth": 1,
"marginTop": 10,
"opacity": 1,
"padding": 15,
}
}
testID="login.signin.button"
>
<Text
style={
Array [
Object {
"color": "#166de0",
"fontSize": 17,
"textAlign": "center",
},
Object {},
]
}
>
Sign in
</Text>
</View>
<View
accessibilityRole="button"
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"borderColor": "transparent",
"marginTop": 15,
"opacity": 1,
}
}
>
<Text
style={
Object {
"color": "#2389d7",
}
}
testID="login.forgot"
>
I forgot my password
</Text>
</View>
</View>
</RCTScrollView>
</RCTSafeAreaView>
`;

452
app/screens/login/index.tsx Normal file
View file

@ -0,0 +1,452 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {
ActivityIndicator,
Image,
InteractionManager,
Keyboard,
SafeAreaView,
StatusBar,
StyleProp,
Text,
TextInput,
TextStyle,
TouchableWithoutFeedback,
View,
ViewStyle,
} from 'react-native';
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 {FORGOT_PASSWORD, MFA} from '@constants/screens';
import FormattedText from '@components/formatted_text';
import {useManagedConfig} from '@mattermost/react-native-emm';
import {scheduleExpiredNotification} from '@requests/remote/push_notification';
import {login} from '@requests/remote/user';
import {goToScreen, resetToChannel} from '@screens/navigation';
import {t} from '@utils/i18n';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type LoginProps = {
componentId: string;
config: ClientConfig;
license: ClientLicense;
theme: Theme;
};
export const MFA_EXPECTED_ERRORS = ['mfa.validate_token.authenticate.app_error', 'ent.mfa.validate_token.authenticate.app_error'];
const Login: NavigationFunctionComponent = ({config, license, theme}: LoginProps) => {
const styles = getStyleSheet(theme);
const loginRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const scrollRef = useRef<KeyboardAwareScrollView>(null);
const intl = useIntl();
const managedConfig = useManagedConfig();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<ClientErrorWithIntl | string | undefined | null>();
const [loginId, setLoginId] = useState<string>('');
const [password, setPassword] = useState<string>('');
// useEffect to set userName for EMM
useEffect(() => {
const setEmmUsernameIfAvailable = async () => {
if (managedConfig?.username && loginRef.current) {
loginRef.current.setNativeProps({text: managedConfig.username});
setLoginId(managedConfig.username);
}
};
setEmmUsernameIfAvailable();
}, []);
const preSignIn = preventDoubleTap(async () => {
setIsLoading(true);
setError(null);
Keyboard.dismiss();
InteractionManager.runAfterInteractions(async () => {
if (!loginId) {
t('login.noEmail');
t('login.noEmailLdapUsername');
t('login.noEmailUsername');
t('login.noEmailUsernameLdapUsername');
t('login.noLdapUsername');
t('login.noUsername');
t('login.noUsernameLdapUsername');
// it's slightly weird to be constructing the message ID, but it's a bit nicer than triply nested if statements
let msgId = 'login.no';
if (config.EnableSignInWithEmail === 'true') {
msgId += 'Email';
}
if (config.EnableSignInWithUsername === 'true') {
msgId += 'Username';
}
if (license.IsLicensed === 'true' && config.EnableLdap === 'true') {
msgId += 'LdapUsername';
}
const ldapUsername = intl.formatMessage({
id: 'login.ldapUsernameLower',
defaultMessage: 'AD/LDAP username',
});
setIsLoading(false);
setError(intl.formatMessage(
{
id: msgId,
defaultMessage: '',
},
{
ldapUsername: config.LdapLoginFieldName || ldapUsername,
},
));
return;
}
if (!password) {
setIsLoading(false);
setError(intl.formatMessage({
id: t('login.noPassword'),
defaultMessage: 'Please enter your password',
}));
return;
}
signIn();
});
});
const signIn = async () => {
const result = await login({loginId: loginId.toLowerCase(), password, config, license});
if (checkLoginResponse(result)) {
await goToChannel();
}
};
const goToChannel = async () => {
await scheduleExpiredNotification(intl);
resetToChannel();
};
const checkLoginResponse = (data: any) => {
if (MFA_EXPECTED_ERRORS.includes(data?.error?.server_error_id)) {
goToMfa();
setIsLoading(false);
return false;
}
if (data?.error) {
setIsLoading(false);
setError(getLoginErrorMessage(data.error));
return false;
}
setIsLoading(false);
return true;
};
const goToMfa = () => {
const screen = MFA;
const title = intl.formatMessage({id: 'mobile.routes.mfa', defaultMessage: 'Multi-factor Authentication'});
goToScreen(screen, title, {goToChannel, loginId, password, config, license, theme});
};
const getLoginErrorMessage = (loginError: any) => {
return (getServerErrorForLogin(loginError) || loginError);
};
const getServerErrorForLogin = (serverError: any) => {
if (!serverError) {
return null;
}
const errorId = serverError.server_error_id;
if (!errorId) {
return serverError.message;
}
if (errorId === 'store.sql_user.get_for_login.app_error' || errorId === 'ent.ldap.do_login.user_not_registered.app_error') {
return {
intl: {
id: t('login.userNotFound'),
defaultMessage: 'We couldn\'t find an account matching your login credentials.',
},
};
}
if (errorId === 'api.user.check_user_password.invalid.app_error' || errorId === 'ent.ldap.do_login.invalid_password.app_error') {
return {
intl: {
id: t('login.invalidPassword'),
defaultMessage: 'Your password is incorrect.',
},
};
}
return serverError.message;
};
const onPressForgotPassword = () => {
const screen = FORGOT_PASSWORD;
const title = intl.formatMessage({id: 'password_form.title', defaultMessage: 'Password Reset'});
goToScreen(screen, title, {theme});
};
const createLoginPlaceholder = () => {
const {formatMessage} = intl;
const loginPlaceholders = [];
if (config.EnableSignInWithEmail === 'true') {
loginPlaceholders.push(formatMessage({id: 'login.email', defaultMessage: 'Email'}));
}
if (config.EnableSignInWithUsername === 'true') {
loginPlaceholders.push(formatMessage({id: 'login.username', defaultMessage: 'Username'}));
}
if (license.IsLicensed === 'true' && license.LDAP === 'true' && config.EnableLdap === 'true') {
if (config.LdapLoginFieldName) {
loginPlaceholders.push(config.LdapLoginFieldName);
} else {
loginPlaceholders.push(formatMessage({id: 'login.ldapUsername', defaultMessage: 'AD/LDAP Username'}));
}
}
if (loginPlaceholders.length >= 2) {
return loginPlaceholders.slice(0, loginPlaceholders.length - 1).join(', ') +
` ${formatMessage({id: 'login.or', defaultMessage: 'or'})} ` +
loginPlaceholders[loginPlaceholders.length - 1];
}
if (loginPlaceholders.length === 1) {
return loginPlaceholders[0];
}
return '';
};
const onBlur = () => {
loginRef?.current?.blur();
passwordRef?.current?.blur();
Keyboard.dismiss();
};
const onLoginChange = useCallback((text) => {
setLoginId(text);
}, []);
const onPasswordChange = useCallback((text) => {
setPassword(text);
}, []);
const onPasswordFocus = useCallback(() => {
passwordRef?.current?.focus();
}, []);
// **** **** **** RENDER METHOD **** **** ****
const renderProceedButton = () => {
if (isLoading) {
return (
<ActivityIndicator
animating={true}
size='small'
/>
);
}
const additionalStyle: StyleProp<ViewStyle> = {
...(config.EmailLoginButtonColor && {
backgroundColor: config.EmailLoginButtonColor,
}),
...(config.EmailLoginButtonBorderColor && {
borderColor: config.EmailLoginButtonBorderColor,
}),
};
const additionalTextStyle: StyleProp<TextStyle> = {
...(config.EmailLoginButtonTextColor && {
color: config.EmailLoginButtonTextColor,
}),
};
return (
<Button
testID='login.signin.button'
onPress={preSignIn}
containerStyle={[styles.signupButton, additionalStyle]}
>
<FormattedText
id='login.signIn'
defaultMessage='Sign in'
style={[styles.signupButtonText, additionalTextStyle]}
/>
</Button>
);
};
return (
<SafeAreaView style={styles.container}>
<StatusBar/>
<TouchableWithoutFeedback
onPress={onBlur}
accessible={false}
>
<KeyboardAwareScrollView
ref={scrollRef}
style={styles.container}
contentContainerStyle={styles.innerContainer}
keyboardShouldPersistTaps='handled'
enableOnAndroid={true}
>
<Image
source={require('@assets/images/logo.png')}
style={{height: 72, resizeMode: 'contain'}}
/>
{config?.SiteName && (<View testID='login.screen'>
<Text style={styles.header}>{config?.SiteName}</Text>
<FormattedText
style={styles.subheader}
id='web.root.signup_info'
defaultMessage='All team communication in one place, searchable and accessible anywhere'
/>
</View>)}
{error && (
<ErrorText
testID='login.error.text'
error={error}
theme={theme}
/>
)}
<TextInput
testID='login.username.input'
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit={false}
disableFullscreenUI={true}
keyboardType='email-address'
onChangeText={onLoginChange}
onSubmitEditing={onPasswordFocus}
placeholder={createLoginPlaceholder()}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
ref={loginRef}
returnKeyType='next'
style={styles.inputBox}
underlineColorAndroid='transparent'
value={loginId} //to remove
/>
<TextInput
testID='login.password.input'
autoCapitalize='none'
autoCorrect={false}
disableFullscreenUI={true}
onChangeText={onPasswordChange}
onSubmitEditing={preSignIn}
style={styles.inputBox}
placeholder={intl.formatMessage({
id: 'login.password',
defaultMessage: 'Password',
})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
ref={passwordRef}
returnKeyType='go'
secureTextEntry={true}
underlineColorAndroid='transparent'
value={password} //to remove
/>
{renderProceedButton()}
{(config.EnableSignInWithEmail === 'true' || config.EnableSignInWithUsername === 'true') && (
<Button
onPress={onPressForgotPassword}
containerStyle={[styles.forgotPasswordBtn]}
>
<FormattedText
id='login.forgot'
defaultMessage='I forgot my password'
style={styles.forgotPasswordTxt}
testID={'login.forgot'}
/>
</Button>
)}
</KeyboardAwareScrollView>
</TouchableWithoutFeedback>
</SafeAreaView>
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
},
innerContainer: {
alignItems: 'center',
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: 15,
paddingVertical: 50,
},
forgotPasswordBtn: {
borderColor: 'transparent',
marginTop: 15,
},
forgotPasswordTxt: {
color: theme.linkColor,
},
inputBox: {
fontSize: 16,
height: 45,
borderColor: 'gainsboro',
borderWidth: 1,
marginTop: 5,
marginBottom: 5,
paddingLeft: 10,
alignSelf: 'stretch',
borderRadius: 3,
color: theme.centerChannelColor,
},
subheader: {
textAlign: 'center',
fontSize: 16,
fontWeight: '300',
color: changeOpacity(theme.centerChannelColor, 0.6),
marginBottom: 15,
lineHeight: 22,
},
signupButton: {
borderRadius: 3,
borderColor: theme.buttonBg,
borderWidth: 1,
alignItems: 'center',
alignSelf: 'stretch',
marginTop: 10,
padding: 15,
},
signupButtonText: {
textAlign: 'center',
color: theme.buttonBg,
fontSize: 17,
},
header: {
textAlign: 'center',
marginTop: 15,
marginBottom: 15,
fontSize: 32,
fontWeight: '600',
},
}));
export default Login;

Some files were not shown because too many files have changed in this diff Show more