From 926ffe96c5e5b23b934fc82e7371c5a7f65861e2 Mon Sep 17 00:00:00 2001 From: enahum Date: Wed, 17 Jan 2018 18:17:02 -0300 Subject: [PATCH] Android share extension (#1352) * Android share extension * Feedback review * Feedback review --- Makefile | 2 +- android/app/src/main/AndroidManifest.xml | 15 + .../mattermost/rnbeta/MainApplication.java | 6 +- .../com/mattermost/share/RealPathUtil.java | 175 +++++ .../com/mattermost/share/ShareActivity.java | 13 + .../com/mattermost/share/ShareModule.java | 199 ++++++ .../com/mattermost/share/SharePackage.java | 27 + app/constants/view.js | 3 +- app/mattermost.js | 10 +- app/reducers/views/extension.js | 19 + app/reducers/views/index.js | 2 + app/store/index.js | 2 +- assets/base/i18n/en.json | 5 + index.android.js | 11 + package.json | 1 + share_extension/android/actions/index.js | 29 + .../extension_channels/extension_channels.js | 269 ++++++++ .../android/extension_channels/index.js | 22 + .../channel_button/channel_button.js | 82 +++ .../extension_post/channel_button/index.js | 16 + .../android/extension_post/extension_post.js | 608 ++++++++++++++++++ .../android/extension_post/index.js | 24 + .../extension_post/team_button/index.js | 16 + .../extension_post/team_button/team_button.js | 76 +++ .../extension_teams/extension_teams.js | 126 ++++ .../android/extension_teams/index.js | 28 + .../extension_teams/team_item/index.js | 13 + .../extension_teams/team_item/team_item.js | 123 ++++ share_extension/android/index.js | 62 ++ share_extension/android/navigation.js | 41 ++ share_extension/android/selectors/index.js | 164 +++++ .../{ios => common}/extension_channel_item.js | 4 +- .../{ => common}/icons/channel_type.js | 0 share_extension/{ => common}/icons/excel.js | 0 share_extension/{ => common}/icons/generic.js | 0 share_extension/common/icons/index.js | 14 + share_extension/{ => common}/icons/pdf.js | 0 share_extension/{ => common}/icons/ppt.js | 0 share_extension/{ => common}/icons/zip.js | 0 share_extension/ios/extension_channels.js | 3 +- share_extension/ios/extension_post.js | 17 +- yarn.lock | 53 +- 42 files changed, 2254 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/com/mattermost/share/RealPathUtil.java create mode 100644 android/app/src/main/java/com/mattermost/share/ShareActivity.java create mode 100644 android/app/src/main/java/com/mattermost/share/ShareModule.java create mode 100644 android/app/src/main/java/com/mattermost/share/SharePackage.java create mode 100644 app/reducers/views/extension.js create mode 100644 index.android.js create mode 100644 share_extension/android/actions/index.js create mode 100644 share_extension/android/extension_channels/extension_channels.js create mode 100644 share_extension/android/extension_channels/index.js create mode 100644 share_extension/android/extension_post/channel_button/channel_button.js create mode 100644 share_extension/android/extension_post/channel_button/index.js create mode 100644 share_extension/android/extension_post/extension_post.js create mode 100644 share_extension/android/extension_post/index.js create mode 100644 share_extension/android/extension_post/team_button/index.js create mode 100644 share_extension/android/extension_post/team_button/team_button.js create mode 100644 share_extension/android/extension_teams/extension_teams.js create mode 100644 share_extension/android/extension_teams/index.js create mode 100644 share_extension/android/extension_teams/team_item/index.js create mode 100644 share_extension/android/extension_teams/team_item/team_item.js create mode 100644 share_extension/android/index.js create mode 100644 share_extension/android/navigation.js create mode 100644 share_extension/android/selectors/index.js rename share_extension/{ios => common}/extension_channel_item.js (96%) rename share_extension/{ => common}/icons/channel_type.js (100%) rename share_extension/{ => common}/icons/excel.js (100%) rename share_extension/{ => common}/icons/generic.js (100%) create mode 100644 share_extension/common/icons/index.js rename share_extension/{ => common}/icons/pdf.js (100%) rename share_extension/{ => common}/icons/ppt.js (100%) rename share_extension/{ => common}/icons/zip.js (100%) diff --git a/Makefile b/Makefile index 15a234d60..a9e20209f 100644 --- a/Makefile +++ b/Makefile @@ -150,7 +150,7 @@ run-ios: | check-device-ios pre-run ## Runs the app on an iOS simulator run-android: | check-device-android pre-run prepare-android-build ## Runs the app on an Android emulator or dev device @if [ $(shell ps -e | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \ echo Starting React Native packager server; \ - node ./node_modules/react-native/local-cli/cli.js start & echo echo Running Android app in development; \ + node ./node_modules/react-native/local-cli/cli.js start & echo Running Android app in development; \ if [ ! -z ${VARIANT} ]; then \ react-native run-android --no-packager --variant=${VARIANT}; \ else \ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ba36bb1a4..5c2258d8a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -56,6 +56,21 @@ + + + + + + // for sharing + + + diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java index 60ee8dcac..cf1904b39 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java @@ -1,5 +1,6 @@ package com.mattermost.rnbeta; +import com.mattermost.share.SharePackage; import android.app.Application; import android.support.annotation.NonNull; import android.content.Context; @@ -70,13 +71,14 @@ public class MainApplication extends NavigationApplication implements INotificat new ReactNativeExceptionHandlerPackage(), new ReactNativeYouTube(), new ReactVideoPackage(), - new RNReactNativeDocViewerPackage() + new RNReactNativeDocViewerPackage(), + new SharePackage() ); } @Override public String getJSMainModuleName() { - return "index"; + return "index.android"; } @Override diff --git a/android/app/src/main/java/com/mattermost/share/RealPathUtil.java b/android/app/src/main/java/com/mattermost/share/RealPathUtil.java new file mode 100644 index 000000000..0c6ea1387 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/share/RealPathUtil.java @@ -0,0 +1,175 @@ +package com.mattermost.share; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.content.ContentUris; +import android.os.Environment; +import android.webkit.MimeTypeMap; + +import android.os.ParcelFileDescriptor; +import java.io.*; +import java.nio.channels.FileChannel; + +// Class based on the steveevers DocumentHelper https://gist.github.com/steveevers/a5af24c226f44bb8fdc3 + +public class RealPathUtil { + public static String getRealPathFromURI(final Context context, final Uri uri) { + + final boolean isKitKatOrNewer = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + + // DocumentProvider + if (isKitKatOrNewer && DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + if ("primary".equalsIgnoreCase(type)) { + return Environment.getExternalStorageDirectory() + "/" + split[1]; + } + } else if (isDownloadsDocument(uri)) { + // DownloadsProvider + + final String id = DocumentsContract.getDocumentId(uri); + final Uri contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); + + return getDataColumn(context, contentUri, null, null); + } else if (isMediaDocument(uri)) { + // MediaProvider + + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + Uri contentUri = null; + if ("image".equals(type)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if ("video".equals(type)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else if ("audio".equals(type)) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + + final String selection = "_id=?"; + final String[] selectionArgs = new String[] { + split[1] + }; + + return getDataColumn(context, contentUri, selection, selectionArgs); + } + } else if ("content".equalsIgnoreCase(uri.getScheme())) { + // MediaStore (and general) + + if (isGooglePhotosUri(uri)) { + return uri.getLastPathSegment(); + } + + String path = getDataColumn(context, uri, null, null); + + if (path != null) { + return path; + } + + // Try save to tmp file, and return tmp file path + return getPathFromSavingTempFile(context, uri); + } else if ("file".equalsIgnoreCase(uri.getScheme())) { + return uri.getPath(); + } + + return null; + } + + public static String getPathFromSavingTempFile(Context context, final Uri uri) { + File tmpFile; + try { + String fileName = uri.getLastPathSegment(); + tmpFile = File.createTempFile("tmp", fileName, context.getCacheDir()); + + ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r"); + + FileChannel src = new FileInputStream(pfd.getFileDescriptor()).getChannel(); + FileChannel dst = new FileOutputStream(tmpFile).getChannel(); + dst.transferFrom(src, 0, src.size()); + src.close(); + dst.close(); + } catch (IOException ex) { + return null; + } + return tmpFile.getAbsolutePath(); + } + + public static String getDataColumn(Context context, Uri uri, String selection, + String[] selectionArgs) { + + Cursor cursor = null; + final String column = "_data"; + final String[] projection = { + column + }; + + try { + cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, + null); + if (cursor != null && cursor.moveToFirst()) { + final int index = cursor.getColumnIndexOrThrow(column); + return cursor.getString(index); + } + } finally { + if (cursor != null) + cursor.close(); + } + return null; + } + + + public static boolean isExternalStorageDocument(Uri uri) { + return "com.android.externalstorage.documents".equals(uri.getAuthority()); + } + + public static boolean isDownloadsDocument(Uri uri) { + return "com.android.providers.downloads.documents".equals(uri.getAuthority()); + } + + public static boolean isMediaDocument(Uri uri) { + return "com.android.providers.media.documents".equals(uri.getAuthority()); + } + + public static boolean isGooglePhotosUri(Uri uri) { + return "com.google.android.apps.photos.content".equals(uri.getAuthority()); + } + + public static String getExtension(String uri) { + if (uri == null) { + return null; + } + + int dot = uri.lastIndexOf("."); + if (dot >= 0) { + return uri.substring(dot); + } else { + // No extension. + return ""; + } + } + + public static String getMimeType(File file) { + + String extension = getExtension(file.getName()); + + if (extension.length() > 0) + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.substring(1)); + + return "application/octet-stream"; + } + + public static String getMimeType(String filePath) { + File file = new File(filePath); + return getMimeType(file); + } +} diff --git a/android/app/src/main/java/com/mattermost/share/ShareActivity.java b/android/app/src/main/java/com/mattermost/share/ShareActivity.java new file mode 100644 index 000000000..b15a9ad97 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/share/ShareActivity.java @@ -0,0 +1,13 @@ +package com.mattermost.share; + +import android.content.Intent; +import android.os.Bundle; + +import com.facebook.react.ReactActivity; + +public class ShareActivity extends ReactActivity { + @Override + protected String getMainComponentName() { + return "MattermostShare"; + } +} diff --git a/android/app/src/main/java/com/mattermost/share/ShareModule.java b/android/app/src/main/java/com/mattermost/share/ShareModule.java new file mode 100644 index 000000000..f92c2efdc --- /dev/null +++ b/android/app/src/main/java/com/mattermost/share/ShareModule.java @@ -0,0 +1,199 @@ +package com.mattermost.share; + +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.Arguments; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; + +import android.graphics.Bitmap; +import java.io.InputStream; +import java.io.File; +import java.util.ArrayList; +import javax.annotation.Nonnull; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONException; + +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class ShareModule extends ReactContextBaseJavaModule { + private final OkHttpClient client = new OkHttpClient(); + public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + public ShareModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return "MattermostShare"; + } + + @ReactMethod + public void clear() { + Activity currentActivity = getCurrentActivity(); + + if (currentActivity != null) { + Intent intent = currentActivity.getIntent(); + intent.setAction(""); + intent.removeExtra(Intent.EXTRA_TEXT); + intent.removeExtra(Intent.EXTRA_STREAM); + } + } + + @ReactMethod + public void close(ReadableMap data) { + getCurrentActivity().finish(); + + if (data != null) { + ReadableArray files = data.getArray("files"); + String serverUrl = data.getString("url"); + String token = data.getString("token"); + JSONObject postData = buildPostObject(data); + + if (files.size() > 0) { + uploadFiles(serverUrl, token, files, postData); + } else { + try { + post(serverUrl, token, postData); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + @ReactMethod + public void data(Promise promise) { + promise.resolve(processIntent()); + } + + public WritableArray processIntent() { + WritableMap map = Arguments.createMap(); + WritableArray items = Arguments.createArray(); + + String text = ""; + String type = ""; + String action = ""; + + Activity currentActivity = getCurrentActivity(); + + if (currentActivity != null) { + Intent intent = currentActivity.getIntent(); + action = intent.getAction(); + type = intent.getType(); + if (type == null) { + type = ""; + } + + if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type)) { + text = intent.getStringExtra(Intent.EXTRA_TEXT); + map.putString("value", text); + map.putString("type", type); + items.pushMap(map); + } else if (Intent.ACTION_SEND.equals(action)) { + Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); + text = "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri); + map.putString("value", text); + map.putString("type", type); + items.pushMap(map); + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + ArrayList uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + for (Uri uri : uris) { + String filePath = RealPathUtil.getRealPathFromURI(currentActivity, uri); + map = Arguments.createMap(); + text = "file://" + filePath; + map.putString("value", text); + map.putString("type", RealPathUtil.getMimeType(filePath)); + items.pushMap(map); + } + } + } + + return items; + } + + private JSONObject buildPostObject(ReadableMap data) { + JSONObject json = new JSONObject(); + try { + json.put("user_id", data.getString("currentUserId")); + json.put("channel_id", data.getString("channelId")); + json.put("message", data.getString("value")); + } catch (JSONException e) { + e.printStackTrace(); + } + return json; + } + + private void post(String serverUrl, String token, JSONObject postData) throws IOException { + RequestBody body = RequestBody.create(JSON, postData.toString()); + Request request = new Request.Builder() + .header("Authorization", "BEARER " + token) + .url(serverUrl + "/api/v4/posts") + .post(body) + .build(); + Response response = client.newCall(request).execute(); + } + + private void uploadFiles(String serverUrl, String token, ReadableArray files, JSONObject postData) { + try { + MultipartBody.Builder builder = new MultipartBody.Builder() + .setType(MultipartBody.FORM); + + for(int i = 0 ; i < files.size() ; i++) { + ReadableMap file = files.getMap(i); + String filePath = file.getString("fullPath").replaceFirst("file://", ""); + File fileInfo = new File(filePath); + if (fileInfo.exists()) { + final MediaType MEDIA_TYPE = MediaType.parse(file.getString("mimeType")); + builder.addFormDataPart("files", file.getString("filename"), RequestBody.create(MEDIA_TYPE, fileInfo)); + } + } + + builder.addFormDataPart("channel_id", postData.getString("channel_id")); + RequestBody body = builder.build(); + Request request = new Request.Builder() + .header("Authorization", "BEARER " + token) + .url(serverUrl + "/api/v4/files") + .post(body) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful()) { + String responseData = response.body().string(); + JSONObject responseJson = new JSONObject(responseData); + JSONArray fileInfoArray = responseJson.getJSONArray("file_infos"); + JSONArray file_ids = new JSONArray(); + for(int i = 0 ; i < fileInfoArray.length() ; i++) { + JSONObject fileInfo = fileInfoArray.getJSONObject(i); + file_ids.put(fileInfo.getString("id")); + } + postData.put("file_ids", file_ids); + post(serverUrl, token, postData); + } + } catch (IOException e) { + e.printStackTrace(); + } + + } catch (JSONException e) { + e.printStackTrace(); + } + } +} diff --git a/android/app/src/main/java/com/mattermost/share/SharePackage.java b/android/app/src/main/java/com/mattermost/share/SharePackage.java new file mode 100644 index 000000000..a1c745f96 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/share/SharePackage.java @@ -0,0 +1,27 @@ +package com.mattermost.share; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.uimanager.ViewManager; +import com.facebook.react.ReactPackage; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class SharePackage implements ReactPackage { + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return Arrays.asList(new ShareModule(reactContext)); + } + + public List> createJSModules() { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/app/constants/view.js b/app/constants/view.js index ff315f64d..6c74e8b80 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -59,7 +59,8 @@ const ViewTypes = keyMirror({ SET_LAST_UPGRADE_CHECK: null, - ADD_RECENT_EMOJI: null + ADD_RECENT_EMOJI: null, + EXTENSION_SELECTED_TEAM_ID: null }); export default { diff --git a/app/mattermost.js b/app/mattermost.js index 48b9c3dda..d7e894fae 100644 --- a/app/mattermost.js +++ b/app/mattermost.js @@ -415,14 +415,10 @@ export default class Mattermost { if (Platform.OS === 'android') { // In case of Android we need to handle the bridge being initialized by HeadlessJS Promise.resolve(Navigation.isAppLaunched()).then((appLaunched) => { - if (notification) { - if (appLaunched) { - this.launchApp(); // App is launched -> show UI - } else { - new NativeEventsReceiver().appLaunched(this.launchApp); // App hasn't been launched yet -> show the UI only when needed. - } + if (appLaunched) { + this.launchApp(); // App is launched -> show UI } else { - this.launchApp(); + new NativeEventsReceiver().appLaunched(this.launchApp); // App hasn't been launched yet -> show the UI only when needed. } }); } else if (isNotActive) { diff --git a/app/reducers/views/extension.js b/app/reducers/views/extension.js new file mode 100644 index 000000000..09bca2b64 --- /dev/null +++ b/app/reducers/views/extension.js @@ -0,0 +1,19 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. +import {combineReducers} from 'redux'; + +import {ViewTypes} from 'app/constants'; + +function selectedTeamId(state = '', action) { + switch (action.type) { + case ViewTypes.EXTENSION_SELECTED_TEAM_ID: { + return action.data; + } + default: + return state; + } +} + +export default combineReducers({ + selectedTeamId +}); diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js index bd0572d0e..7dcea57aa 100644 --- a/app/reducers/views/index.js +++ b/app/reducers/views/index.js @@ -5,6 +5,7 @@ import {combineReducers} from 'redux'; import channel from './channel'; import clientUpgrade from './client_upgrade'; +import extension from './extension'; import fetchCache from './fetch_cache'; import i18n from './i18n'; import login from './login'; @@ -18,6 +19,7 @@ import thread from './thread'; export default combineReducers({ channel, clientUpgrade, + extension, fetchCache, i18n, login, diff --git a/app/store/index.js b/app/store/index.js index 000864a68..01476c841 100644 --- a/app/store/index.js +++ b/app/store/index.js @@ -43,7 +43,7 @@ const setTransforms = [ export default function configureAppStore(initialState) { const viewsBlackListFilter = createBlacklistFilter( 'views', - ['login', 'root'] + ['extension', 'login', 'root'] ); const typingBlackListFilter = createBlacklistFilter( diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index c45d4b590..78d13bc2a 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -2007,6 +2007,10 @@ "mobile.error_handler.button": "Relaunch", "mobile.error_handler.description": "\nClick relaunch to open the app again. After restart, you can report the problem from the settings menu.\n", "mobile.error_handler.title": "Unexpected error occurred", + "mobile.extension.authentication_required": "Authentication required: Please first login using the app.", + "mobile.extension.permission": "Mattermost needs access to the device storage to share files.", + "mobile.extension.file_limit": "Sharing is limited to a maximum of 5 files.", + "mobile.extension.title": "Share in Mattermost", "mobile.file_upload.camera": "Take Photo or Video", "mobile.file_upload.library": "Photo Library", "mobile.file_upload.max_warning": "Uploads limited to 5 files maximum.", @@ -2114,6 +2118,7 @@ "mobile.routes.mfa": "Multi-factor Authentication", "mobile.routes.postsList": "Posts List", "mobile.routes.saml": "Single SignOn", + "mobile.routes.selectChannel": "Select Channel", "mobile.routes.selectTeam": "Select Team", "mobile.routes.settings": "Settings", "mobile.routes.sso": "Single Sign-On", diff --git a/index.android.js b/index.android.js new file mode 100644 index 000000000..3973260de --- /dev/null +++ b/index.android.js @@ -0,0 +1,11 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {AppRegistry} from 'react-native'; + +/* eslint-disable no-unused-vars */ +import Mattermost from 'app/mattermost'; +import ShareExtension from 'share_extension/android'; + +AppRegistry.registerComponent('MattermostShare', () => ShareExtension); +const app = new Mattermost(); diff --git a/package.json b/package.json index 61e777b99..9fb42c0fa 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react-native-vector-icons": "4.4.2", "react-native-video": "2.0.0", "react-native-youtube": "1.0.1", + "react-navigation": "1.0.0-beta.23", "react-redux": "5.0.6", "redux": "3.7.2", "redux-batched-actions": "0.2.0", diff --git a/share_extension/android/actions/index.js b/share_extension/android/actions/index.js new file mode 100644 index 000000000..665a9fdcb --- /dev/null +++ b/share_extension/android/actions/index.js @@ -0,0 +1,29 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {fetchMyChannelsAndMembers} from 'mattermost-redux/actions/channels'; + +import {ViewTypes} from 'app/constants'; +import {getDefaultChannelForTeam} from 'share_extension/android/selectors'; + +export function getTeamChannels(teamId) { + return async (dispatch, getState) => { + let defaultChannel = getDefaultChannelForTeam(getState(), teamId); + + if (!defaultChannel) { + await fetchMyChannelsAndMembers(teamId)(dispatch, getState); + defaultChannel = getDefaultChannelForTeam(getState(), teamId); + } + + return defaultChannel.id; + }; +} + +export function extensionSelectTeamId(teamId) { + return async (dispatch, getState) => { + dispatch({ + type: ViewTypes.EXTENSION_SELECTED_TEAM_ID, + data: teamId + }, getState); + }; +} diff --git a/share_extension/android/extension_channels/extension_channels.js b/share_extension/android/extension_channels/extension_channels.js new file mode 100644 index 000000000..0b42319f0 --- /dev/null +++ b/share_extension/android/extension_channels/extension_channels.js @@ -0,0 +1,269 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; +import {NavigationActions} from 'react-navigation'; +import { + ActivityIndicator, + SectionList, + Text, + View +} from 'react-native'; + +import {Preferences} from 'mattermost-redux/constants'; + +import SearchBar from 'app/components/search_bar'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import ExtensionChannelItem from 'share_extension/common/extension_channel_item'; + +const defaultTheme = Preferences.THEMES.default; + +export default class ExtensionTeam extends PureComponent { + static propTypes = { + directChannels: PropTypes.array, + navigation: PropTypes.object.isRequired, + privateChannels: PropTypes.array, + publicChannels: PropTypes.array + }; + + static defaultProps = { + directChannels: [], + privateChannels: [], + publicChannels: [] + }; + + static contextTypes = { + intl: intlShape + }; + + static navigationOptions = ({navigation}) => ({ + title: navigation.state.params.title + }); + + state = { + sections: null + }; + + componentWillMount() { + this.buildSections(); + } + + buildSections = (term) => { + const sections = []; + let { + directChannels: directFiltered, + privateChannels: privateFiletered, + publicChannels: publicFiltered + } = this.props; + + if (term) { + directFiltered = directFiltered.filter((c) => c.display_name.toLowerCase().includes(term.toLowerCase())); + privateFiletered = privateFiletered.filter((c) => c.display_name.toLowerCase().includes(term.toLowerCase())); + publicFiltered = publicFiltered.filter((c) => c.display_name.toLowerCase().includes(term.toLowerCase())); + } + + if (publicFiltered.length) { + sections.push({ + id: 'sidebar.channels', + defaultMessage: 'PUBLIC CHANNELS', + data: publicFiltered + }); + } + + if (privateFiletered.length) { + sections.push({ + id: 'sidebar.pg', + defaultMessage: 'PRIVATE CHANNELS', + data: privateFiletered + }); + } + + if (directFiltered.length) { + sections.push({ + id: 'sidebar.direct', + defaultMessage: 'DIRECT MESSAGES', + data: directFiltered + }); + } + + this.setState({sections}); + }; + + handleSelectChannel = async (channel) => { + const {state} = this.props.navigation; + const backAction = NavigationActions.back(); + + if (state.params && state.params.onSelectChannel) { + state.params.onSelectChannel(channel.id); + } + + this.props.navigation.dispatch(backAction); + }; + + handleSearch = (term) => { + this.setState({term}, () => { + if (this.throttleTimeout) { + clearTimeout(this.throttleTimeout); + } + + this.throttleTimeout = setTimeout(() => { + this.buildSections(term); + }, 300); + }); + }; + + keyExtractor = (item) => item.id; + + renderBody = (styles) => { + const {sections} = this.state; + + if (!sections) { + return ( + + + + ); + } + + return ( + + ); + }; + + renderItem = ({item}) => { + const {navigation} = this.props; + const {params = {}} = navigation.state; + const {currentChannelId} = params; + + return ( + + ); + }; + + renderItemSeparator = () => { + const styles = getStyleSheet(defaultTheme); + + return ( + + ); + }; + + renderSearchBar = (styles) => { + const {formatMessage} = this.context.intl; + + return ( + + + + ); + }; + + renderSectionHeader = ({section}) => { + const {intl} = this.context; + const styles = getStyleSheet(defaultTheme); + const { + defaultMessage, + id + } = section; + + return ( + + + + {intl.formatMessage({id, defaultMessage}).toUpperCase()} + + + + ); + }; + + render() { + const styles = getStyleSheet(defaultTheme); + + return ( + + {this.renderBody(styles)} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + flex: { + flex: 1 + }, + separator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1 + }, + loadingContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center' + }, + searchContainer: { + paddingBottom: 2 + }, + searchBarInput: { + backgroundColor: '#fff', + color: theme.centerChannelColor, + fontSize: 15 + }, + titleContainer: { + height: 30 + }, + title: { + color: theme.centerChannelColor, + fontSize: 15, + height: 30, + textAlignVertical: 'center', + paddingHorizontal: 15 + }, + errorContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + paddingHorizontal: 15 + }, + error: { + color: theme.errorTextColor, + fontSize: 14 + } + }; +}); diff --git a/share_extension/android/extension_channels/index.js b/share_extension/android/extension_channels/index.js new file mode 100644 index 000000000..461fd00b0 --- /dev/null +++ b/share_extension/android/extension_channels/index.js @@ -0,0 +1,22 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import { + getExtensionSortedDirectChannels, + getExtensionSortedPrivateChannels, + getExtensionSortedPublicChannels +} from 'share_extension/android/selectors'; + +import ExtensionChannels from './extension_channels'; + +function mapStateToProps(state) { + return { + publicChannels: getExtensionSortedPublicChannels(state), + privateChannels: getExtensionSortedPrivateChannels(state), + directChannels: getExtensionSortedDirectChannels(state) + }; +} + +export default connect(mapStateToProps)(ExtensionChannels); diff --git a/share_extension/android/extension_post/channel_button/channel_button.js b/share_extension/android/extension_post/channel_button/channel_button.js new file mode 100644 index 000000000..9203d271a --- /dev/null +++ b/share_extension/android/extension_post/channel_button/channel_button.js @@ -0,0 +1,82 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; +import { + Text, + TouchableHighlight, + View +} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class ChannelButton extends PureComponent { + static propTypes = { + channel: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + onPress: PropTypes.func.isRequired + }; + + static contextTypes = { + intl: intlShape + }; + + render() { + const {formatMessage} = this.context.intl; + const {onPress, channel, theme} = this.props; + const channelName = channel ? channel.display_name : ''; + const styles = getStyleSheet(theme); + + return ( + + + + {formatMessage({id: 'mobile.share_extension.channel', defaultMessage: 'Channel'})} + + + {channelName} + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + flex: { + flex: 1 + }, + buttonContainer: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), + borderTopWidth: 1, + height: 70, + paddingHorizontal: 15 + }, + buttonWrapper: { + alignItems: 'flex-start', + flex: 1 + }, + buttonLabel: { + fontSize: 16, + marginTop: 16, + marginBottom: 3 + }, + buttonValue: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 14 + } + }; +}); diff --git a/share_extension/android/extension_post/channel_button/index.js b/share_extension/android/extension_post/channel_button/index.js new file mode 100644 index 000000000..ddb2c980c --- /dev/null +++ b/share_extension/android/extension_post/channel_button/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; + +import ChannelButton from './channel_button'; + +function mapStateToProps(state, ownProps) { + return { + channel: getChannel(state, ownProps.channelId) + }; +} + +export default connect(mapStateToProps)(ChannelButton); diff --git a/share_extension/android/extension_post/extension_post.js b/share_extension/android/extension_post/extension_post.js new file mode 100644 index 000000000..f9eb7ab77 --- /dev/null +++ b/share_extension/android/extension_post/extension_post.js @@ -0,0 +1,608 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import {NavigationActions} from 'react-navigation'; +import TouchableItem from 'react-navigation/src/views/TouchableItem'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; +import { + Image, + NativeModules, + PermissionsAndroid, + ScrollView, + Text, + TextInput, + View +} from 'react-native'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; +import Video from 'react-native-video'; +import LocalAuth from 'react-native-local-auth'; + +import {Preferences} from 'mattermost-redux/constants'; +import {lookupMimeType} from 'mattermost-redux/utils/file_utils'; + +import PaperPlane from 'app/components/paper_plane'; +import mattermostManaged from 'app/mattermost_managed'; +import {emptyFunction} from 'app/utils/general'; +import {wrapWithPreventDoubleTap} from 'app/utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import { + ExcelSvg, + GenericSvg, + PdfSvg, + PptSvg, + ZipSvg +} from 'share_extension/common/icons'; + +import ChannelButton from './channel_button'; +import TeamButton from './team_button'; + +const defalultTheme = Preferences.THEMES.default; +const extensionSvg = { + csv: ExcelSvg, + pdf: PdfSvg, + ppt: PptSvg, + pptx: PptSvg, + xls: ExcelSvg, + xlsx: ExcelSvg, + zip: ZipSvg +}; +const ShareExtension = NativeModules.MattermostShare; +const INPUT_HEIGHT = 150; +const MAX_MESSAGE_LENGTH = 4000; + +export default class ExtensionPost extends PureComponent { + static propTypes = { + channelId: PropTypes.string.isRequired, + currentUserId: PropTypes.string.isRequired, + navigation: PropTypes.object.isRequired, + teamId: PropTypes.string.isRequired, + token: PropTypes.string, + url: PropTypes.string + }; + + static contextTypes = { + intl: intlShape + }; + + static navigationOptions = ({navigation}) => { + const {params = {}} = navigation.state; + const title = params.title || ''; + const headerLeft = ( + + + + + + ); + const headerRight = ( + + + + + + ); + + return {headerLeft, headerRight, title}; + }; + + constructor(props, context) { + super(props, context); + + props.navigation.setParams({ + title: context.intl.formatMessage({ + id: 'mobile.extension.title', + defaultMessage: 'Share in Mattermost' + }) + }); + + this.state = { + channelId: props.channelId, + files: [], + hasPermission: null, + teamId: props.teamId, + value: '' + }; + } + + componentDidMount() { + this.props.navigation.setParams({ + close: this.onClose, + post: this.onPost + }); + this.auth(); + } + + auth = async () => { + try { + const {formatMessage} = this.context.intl; + const config = await mattermostManaged.getConfig(); + + if (config) { + const authNeeded = config.inAppPinCode && config.inAppPinCode === 'true'; + const vendor = config.vendor || 'Mattermost'; + if (authNeeded) { + const isSecured = await mattermostManaged.isDeviceSecure(); + if (isSecured) { + try { + await LocalAuth.auth({ + reason: formatMessage({ + id: 'mobile.managed.secured_by', + defaultMessage: 'Secured by {vendor}' + }, {vendor}), + fallbackToPasscode: true, + suppressEnterPassword: false + }); + } catch (err) { + return this.onClose(); + } + } + } + } + } catch (e) { + // do nothing + } + + return this.initialize(); + }; + + getInputRef = (ref) => { + this.input = ref; + }; + + goToChannels = wrapWithPreventDoubleTap(() => { + const {formatMessage} = this.context.intl; + const {navigation} = this.props; + const navigateAction = NavigationActions.navigate({ + routeName: 'Channels', + params: { + title: formatMessage({ + id: 'mobile.routes.selectChannel', + defaultMessage: 'Select Channel' + }), + currentChannelId: this.state.channelId, + onSelectChannel: this.handleSelectChannel + } + }); + navigation.dispatch(navigateAction); + }); + + goToTeams = wrapWithPreventDoubleTap(() => { + const {formatMessage} = this.context.intl; + const {navigation} = this.props; + const navigateAction = NavigationActions.navigate({ + routeName: 'Teams', + params: { + title: formatMessage({ + id: 'mobile.routes.selectTeam', + defaultMessage: 'Select Team' + }), + currentTeamId: this.state.teamId, + onSelectTeam: this.handleSelectTeam + } + }); + navigation.dispatch(navigateAction); + }); + + handleBlur = () => { + if (this.input) { + this.input.setNativeProps({ + autoScroll: false + }); + } + }; + + handleFocus = () => { + if (this.input) { + this.input.setNativeProps({ + autoScroll: true + }); + } + }; + + handleSelectChannel = (channelId) => { + this.setState({channelId}); + }; + + handleSelectTeam = (teamId, defaultChannelId) => { + this.setState({teamId, channelId: defaultChannelId}); + }; + + handleTextChange = (value) => { + this.setState({value}); + }; + + initialize = async () => { + const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE); + let granted; + if (!hasPermission) { + granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE + ); + } + + if (hasPermission || granted === PermissionsAndroid.RESULTS.GRANTED) { + const data = await ShareExtension.data(); + return this.loadData(data); + } + + return this.setState({hasPermission: false}); + }; + + loadData = async (items) => { + const {token, url} = this.props; + if (token && url) { + const text = []; + const files = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + switch (item.type) { + case 'text/plain': + text.push(item.value); + break; + default: { + const fullPath = item.value; + const filename = fullPath.replace(/^.*[\\/]/, ''); + const extension = filename.split('.').pop(); + files.push({ + extension, + filename, + fullPath, + mimeType: lookupMimeType(filename.toLowerCase()), + type: item.type + }); + break; + } + } + } + + const value = text.join('\n'); + + this.setState({files, value, hasPermission: true}); + } + }; + + onClose = (data) => { + ShareExtension.close(data.nativeEvent ? null : data); + }; + + onPost = () => { + const {channelId, files, value} = this.state; + const {currentUserId, token, url} = this.props; + + const data = { + channelId, + currentUserId, + files, + token, + url, + value + }; + + this.onClose(data); + }; + + renderBody = () => { + const {formatMessage} = this.context.intl; + const {value} = this.state; + + return ( + + + {this.renderFiles()} + + ); + }; + + renderChannelButton = () => { + const {channelId} = this.state; + + return ( + + ); + }; + + renderFiles = () => { + const {files} = this.state; + + return files.map((file, index) => { + let component; + + if (file.type.startsWith('image')) { + component = ( + + + + ); + } else if (file.type.startsWith('video')) { + component = ( + + + ); + } else { + let SvgIcon = extensionSvg[file.extension]; + if (!SvgIcon) { + SvgIcon = GenericSvg; + } + + component = ( + + + + + + + + ); + } + + return ( + + {component} + + {file.filename} + + + ); + }); + }; + + renderTeamButton = () => { + const {teamId} = this.state; + + return ( + + ); + }; + + render() { + const {formatMessage} = this.context.intl; + const {token, url} = this.props; + const {hasPermission, files} = this.state; + + if (hasPermission === false) { + return ( + + + + {formatMessage({ + id: 'mobile.extension.permission', + defaultMessage: 'Mattermost needs access to the device storage to share files.' + })} + + + + ); + } else if (files.length > 5) { + return ( + + + + {formatMessage({ + id: 'mobile.extension.file_limit', + defaultMessage: 'Sharing is limited to a maximum of 5 files.' + })} + + + + ); + } else if (token && url) { + return ( + + + {this.renderBody()} + + {this.renderTeamButton()} + {this.renderChannelButton()} + + + + ); + } + + return ( + + + {formatMessage({ + id: 'mobile.extension.authentication_required', + defaultMessage: 'Authentication required: Please first login using the app.' + })} + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + left: { + alignItems: 'center', + height: 50, + justifyContent: 'center', + width: 50 + }, + closeButton: { + color: theme.sidebarHeaderTextColor, + fontSize: 20 + }, + flex: { + flex: 1 + }, + container: { + flex: 1, + backgroundColor: theme.centerChannelBg + }, + wrapper: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.05), + flex: 1 + }, + scrollView: { + padding: 15 + }, + input: { + color: theme.centerChannelColor, + fontSize: 17, + height: INPUT_HEIGHT, + marginBottom: 5, + textAlignVertical: 'top', + width: '100%' + }, + unauthenticatedContainer: { + alignItems: 'center', + flex: 1, + paddingHorizontal: 20, + paddingTop: 35 + }, + unauthenticated: { + color: theme.errorTextColor, + fontSize: 14 + }, + fileContainer: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRadius: 4, + borderWidth: 1, + flexDirection: 'row', + height: 48, + marginBottom: 10, + width: '100%' + }, + filename: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 13, + flex: 1 + }, + otherContainer: { + borderBottomLeftRadius: 4, + borderTopLeftRadius: 4, + height: 48, + marginRight: 10, + paddingVertical: 10, + width: 38 + }, + otherWrapper: { + borderRightWidth: 1, + borderRightColor: changeOpacity(theme.centerChannelColor, 0.2), + flex: 1 + }, + fileIcon: { + alignItems: 'center', + justifyContent: 'center', + flex: 1 + }, + imageContainer: { + justifyContent: 'center', + borderBottomLeftRadius: 4, + borderTopLeftRadius: 4, + height: 48, + marginRight: 10, + width: 38, + overflow: 'hidden' + }, + image: { + alignItems: 'center', + borderBottomLeftRadius: 4, + borderTopLeftRadius: 4, + height: 46, + justifyContent: 'center', + overflow: 'hidden', + width: 38 + }, + video: { + backgroundColor: theme.centerChannelBg, + alignItems: 'center', + height: 48, + justifyContent: 'center', + overflow: 'hidden', + width: 38 + } + }; +}); + +const styles = getStyleSheet(defalultTheme); diff --git a/share_extension/android/extension_post/index.js b/share_extension/android/extension_post/index.js new file mode 100644 index 000000000..8cf2c4252 --- /dev/null +++ b/share_extension/android/extension_post/index.js @@ -0,0 +1,24 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +import ExtensionPost from './extension_post'; + +function mapStateToProps(state) { + const {token, url} = state.entities.general.credentials; + + return { + channelId: getCurrentChannelId(state), + currentUserId: getCurrentUserId(state), + teamId: getCurrentTeamId(state), + token, + url + }; +} + +export default connect(mapStateToProps)(ExtensionPost); diff --git a/share_extension/android/extension_post/team_button/index.js b/share_extension/android/extension_post/team_button/index.js new file mode 100644 index 000000000..cbb68c4ca --- /dev/null +++ b/share_extension/android/extension_post/team_button/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getTeam} from 'mattermost-redux/selectors/entities/teams'; + +import TeamButton from './team_button'; + +function mapStateToProps(state, ownProps) { + return { + team: getTeam(state, ownProps.teamId) + }; +} + +export default connect(mapStateToProps)(TeamButton); diff --git a/share_extension/android/extension_post/team_button/team_button.js b/share_extension/android/extension_post/team_button/team_button.js new file mode 100644 index 000000000..7fdb27dfe --- /dev/null +++ b/share_extension/android/extension_post/team_button/team_button.js @@ -0,0 +1,76 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; +import { + Text, + TouchableHighlight, + View +} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class TeamButton extends PureComponent { + static propTypes = { + team: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + onPress: PropTypes.func.isRequired + }; + + static contextTypes = { + intl: intlShape + }; + + render() { + const {formatMessage} = this.context.intl; + const {onPress, team, theme} = this.props; + const teamName = team ? team.display_name : ''; + const styles = getStyleSheet(theme); + + return ( + + + + {formatMessage({id: 'mobile.share_extension.team', defaultMessage: 'Team'})} + + + {teamName} + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + flex: { + flex: 1 + }, + buttonContainer: { + borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), + borderTopWidth: 1, + height: 70, + paddingHorizontal: 15 + }, + buttonWrapper: { + alignItems: 'flex-start', + flex: 1 + }, + buttonLabel: { + fontSize: 16, + marginTop: 16, + marginBottom: 3 + }, + buttonValue: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 14 + } + }; +}); diff --git a/share_extension/android/extension_teams/extension_teams.js b/share_extension/android/extension_teams/extension_teams.js new file mode 100644 index 000000000..46b265cbc --- /dev/null +++ b/share_extension/android/extension_teams/extension_teams.js @@ -0,0 +1,126 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; +import {NavigationActions} from 'react-navigation'; +import { + ActivityIndicator, + FlatList, + View +} from 'react-native'; + +import {Preferences} from 'mattermost-redux/constants'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import TeamItem from './team_item'; + +const defaultTheme = Preferences.THEMES.default; + +export default class ExtensionTeam extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + extensionSelectTeamId: PropTypes.func.isRequired, + getTeamChannels: PropTypes.func.isRequired + }).isRequired, + navigation: PropTypes.object.isRequired, + teamIds: PropTypes.array + }; + + static defaultProps = { + teamIds: [] + }; + + static contextTypes = { + intl: intlShape + }; + + static navigationOptions = ({navigation}) => ({ + title: navigation.state.params.title + }); + + state = { + loading: false + }; + + handleSelectTeam = async (teamId) => { + const {state} = this.props.navigation; + const backAction = NavigationActions.back(); + + if (state.params && state.params.onSelectTeam) { + this.setState({loading: true}); + const channelId = await this.props.actions.getTeamChannels(teamId); + this.props.actions.extensionSelectTeamId(teamId); + state.params.onSelectTeam(teamId, channelId); + } + + this.props.navigation.dispatch(backAction); + }; + + keyExtractor = (item) => item; + + renderItemSeparator = () => { + const styles = getStyleSheet(defaultTheme); + + return ( + + ); + }; + + renderItem = ({item}) => { + const {navigation} = this.props; + const {params} = navigation.state; + + return ( + + ); + }; + + render() { + const styles = getStyleSheet(defaultTheme); + + if (this.state.loading) { + return ( + + + + ); + } + + return ( + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + loadingContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center' + }, + separator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1 + } + }; +}); diff --git a/share_extension/android/extension_teams/index.js b/share_extension/android/extension_teams/index.js new file mode 100644 index 000000000..576a6acee --- /dev/null +++ b/share_extension/android/extension_teams/index.js @@ -0,0 +1,28 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {getMySortedTeamIds} from 'mattermost-redux/selectors/entities/teams'; + +import {extensionSelectTeamId, getTeamChannels} from 'share_extension/android/actions'; + +import ExtensionTeams from './extension_teams'; + +function mapStateToProps(state) { + return { + teamIds: getMySortedTeamIds(state) + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + extensionSelectTeamId, + getTeamChannels + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ExtensionTeams); diff --git a/share_extension/android/extension_teams/team_item/index.js b/share_extension/android/extension_teams/team_item/index.js new file mode 100644 index 000000000..6963717b6 --- /dev/null +++ b/share_extension/android/extension_teams/team_item/index.js @@ -0,0 +1,13 @@ +import {connect} from 'react-redux'; + +import {getTeam} from 'mattermost-redux/selectors/entities/teams'; + +import TeamItem from './team_item'; + +function mapStateToProps(state, ownProps) { + return { + team: getTeam(state, ownProps.teamId) + }; +} + +export default connect(mapStateToProps)(TeamItem); diff --git a/share_extension/android/extension_teams/team_item/team_item.js b/share_extension/android/extension_teams/team_item/team_item.js new file mode 100644 index 000000000..022de34eb --- /dev/null +++ b/share_extension/android/extension_teams/team_item/team_item.js @@ -0,0 +1,123 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + Text, + TouchableHighlight, + View +} from 'react-native'; +import IonIcon from 'react-native-vector-icons/Ionicons'; + +import {wrapWithPreventDoubleTap} from 'app/utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class TeamItem extends PureComponent { + static propTypes = { + currentTeamId: PropTypes.string.isRequired, + onSelectTeam: PropTypes.func.isRequired, + team: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired + }; + + onPress = wrapWithPreventDoubleTap(() => { + const {onSelectTeam, team} = this.props; + onSelectTeam(team.id); + }); + + render() { + const { + currentTeamId, + team, + theme + } = this.props; + const styles = getStyleSheet(theme); + + let current; + if (team.id === currentTeamId) { + current = ( + + + + ); + } + + const icon = ( + + + {team.display_name.substr(0, 2).toUpperCase()} + + + ); + + return ( + + + + {icon} + + {team.display_name} + + {current} + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + flexDirection: 'row', + height: 45, + paddingHorizontal: 15 + }, + item: { + alignItems: 'center', + height: 45, + flex: 1, + flexDirection: 'row' + }, + text: { + color: theme.centerChannelColor, + flex: 1, + fontSize: 16, + paddingRight: 5 + }, + iconContainer: { + alignItems: 'center', + backgroundColor: theme.linkColor, + borderRadius: 2, + height: 30, + justifyContent: 'center', + width: 30, + marginRight: 10 + }, + icon: { + color: theme.sidebarText, + fontFamily: 'OpenSans', + fontSize: 15, + fontWeight: '600' + }, + checkmarkContainer: { + alignItems: 'flex-end' + }, + checkmark: { + color: theme.linkColor, + fontSize: 16 + } + }; +}); diff --git a/share_extension/android/index.js b/share_extension/android/index.js new file mode 100644 index 000000000..61e67db4d --- /dev/null +++ b/share_extension/android/index.js @@ -0,0 +1,62 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import {Provider} from 'react-redux'; +import {IntlProvider} from 'react-intl'; + +import {Client4} from 'mattermost-redux/client'; + +import {getTranslations} from 'app/i18n'; +import initialState from 'app/initial_state'; +import {getCurrentLocale} from 'app/selectors/i18n'; +import configureStore from 'app/store'; + +import {extensionSelectTeamId} from './actions'; +import Navigation from './navigation'; + +export default class ShareApp extends PureComponent { + constructor() { + super(); + + this.store = configureStore(initialState); + this.unsubscribeFromStore = this.store.subscribe(this.listenForHydration); + this.state = {init: false}; + } + + listenForHydration = () => { + const {dispatch, getState} = this.store; + const state = getState(); + if (state.views.root.hydrationComplete) { + const {credentials} = state.entities.general; + const {currentTeamId} = state.entities.teams; + + this.unsubscribeFromStore(); + if (credentials.token && credentials.url) { + Client4.setToken(credentials.token); + Client4.setUrl(credentials.url); + } + extensionSelectTeamId(currentTeamId)(dispatch, getState); + this.setState({init: true}); + } + }; + + render() { + if (!this.state.init) { + return null; + } + + const locale = getCurrentLocale(this.store.getState()); + + return ( + + + + + + ); + } +} diff --git a/share_extension/android/navigation.js b/share_extension/android/navigation.js new file mode 100644 index 000000000..d3fc523b9 --- /dev/null +++ b/share_extension/android/navigation.js @@ -0,0 +1,41 @@ +// // Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// // See License.txt for license information. + +import {StackNavigator as stackNavigator} from 'react-navigation'; + +import {Preferences} from 'mattermost-redux/constants'; + +import ExtensionChannels from './extension_channels'; +import ExtensionPost from './extension_post'; +import ExtensionTeams from './extension_teams'; + +const theme = Preferences.THEMES.default; +const Navigation = stackNavigator({ + Post: { + screen: ExtensionPost + }, + Teams: { + screen: ExtensionTeams + }, + Channels: { + screen: ExtensionChannels + } +}, { + navigationOptions: { + headerStyle: { + backgroundColor: theme.sidebarHeaderBg + }, + headerTitleStyle: { + marginHorizontal: 0, + left: 0, + color: theme.sidebarHeaderTextColor + }, + headerBackTitleStyle: { + color: theme.sidebarHeaderTextColor, + margin: 0 + }, + headerTintColor: theme.sidebarHeaderTextColor + } +}); + +export default Navigation; diff --git a/share_extension/android/selectors/index.js b/share_extension/android/selectors/index.js new file mode 100644 index 000000000..a3e100f25 --- /dev/null +++ b/share_extension/android/selectors/index.js @@ -0,0 +1,164 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {createSelector} from 'reselect'; + +import {General} from 'mattermost-redux/constants'; +import {getAllChannels, getChannelsInTeam, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentUser, getUsers} from 'mattermost-redux/selectors/entities/users'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getLastPostPerChannel} from 'mattermost-redux/selectors/entities/posts'; +import { + getMyPreferences, + getTeammateNameDisplaySetting, + getVisibleTeammate, + getVisibleGroupIds +} from 'mattermost-redux/selectors/entities/preferences'; + +import { + completeDirectChannelDisplayName, + getDirectChannelName, + getGroupDisplayNameFromUserIds, + getUserIdFromChannelName, + isAutoClosed, + sortChannelsByDisplayName +} from 'mattermost-redux/utils/channel_utils'; +import {createIdsSelector} from 'mattermost-redux/utils/helpers'; + +export const getDefaultChannelForTeam = createSelector( + getAllChannels, + (state, teamId) => teamId, + (channels, teamId) => { + return Object.values(channels).find((c) => c.team_id === teamId && c.name === General.DEFAULT_CHANNEL); + } +); + +export const getChannelIdsForExtensionTeam = createIdsSelector( + (state) => state.views.extension.selectedTeamId, + getChannelsInTeam, + (teamId, channelsInTeam) => { + return Array.from(channelsInTeam[teamId] || []); + } +); + +export const getExtensionSortedPublicChannels = createSelector( + getCurrentUser, + getAllChannels, + getMyChannelMemberships, + getChannelIdsForExtensionTeam, + (currentUser, channels, myMembers, teamChannelIds) => { + if (!currentUser) { + return []; + } + + const locale = currentUser.locale || 'en'; + const publicChannels = teamChannelIds.filter((id) => { + if (!myMembers[id]) { + return false; + } + const channel = channels[id]; + return teamChannelIds.includes(id) && channel.type === General.OPEN_CHANNEL; + }).map((id) => channels[id]).sort(sortChannelsByDisplayName.bind(null, locale)); + return publicChannels; + } +); + +export const getExtensionSortedPrivateChannels = createSelector( + getCurrentUser, + getAllChannels, + getMyChannelMemberships, + getChannelIdsForExtensionTeam, + (currentUser, channels, myMembers, teamChannelIds) => { + if (!currentUser) { + return []; + } + + const locale = currentUser.locale || 'en'; + const publicChannels = teamChannelIds.filter((id) => { + if (!myMembers[id]) { + return false; + } + const channel = channels[id]; + return teamChannelIds.includes(id) && channel.type === General.PRIVATE_CHANNEL; + }).map((id) => channels[id]).sort(sortChannelsByDisplayName.bind(null, locale)); + return publicChannels; + } +); + +export const getExtensionSortedDirectChannels = createSelector( + getCurrentUser, + getUsers, + (state) => state.entities.users.profilesInChannel, + getAllChannels, + getVisibleTeammate, + getVisibleGroupIds, + getTeammateNameDisplaySetting, + getConfig, + getMyPreferences, + getLastPostPerChannel, + (currentUser, profiles, profilesInChannel, channels, teammates, groupIds, settings, config, preferences, lastPosts) => { + if (!currentUser) { + return []; + } + + const locale = currentUser.locale || 'en'; + const channelValues = Object.values(channels); + const directChannelsIds = []; + teammates.reduce((result, teammateId) => { + const name = getDirectChannelName(currentUser.id, teammateId); + const channel = channelValues.find((c) => c.name === name); //eslint-disable-line max-nested-callbacks + if (channel) { + const lastPost = lastPosts[channel.id]; + const otherUser = profiles[getUserIdFromChannelName(currentUser.id, channel.name)]; + if (!isAutoClosed(config, preferences, channel, lastPost ? lastPost.create_at : 0, otherUser ? otherUser.delete_at : 0)) { + result.push(channel.id); + } + } + return result; + }, directChannelsIds); + const directChannels = groupIds.filter((id) => { + const channel = channels[id]; + if (channel) { + const lastPost = lastPosts[channel.id]; + return !isAutoClosed(config, preferences, channels[id], lastPost ? lastPost.create_at : 0); + } + + return false; + }).concat(directChannelsIds).map((id) => { + const channel = channels[id]; + if (channel.type === General.GM_CHANNEL) { + return completeDirectGroupInfo(currentUser.id, profiles, profilesInChannel, settings, channel); + } + return completeDirectChannelDisplayName(currentUser.id, profiles, settings, channel); + }).sort(sortChannelsByDisplayName.bind(null, locale)); + return directChannels; + } +); + +function completeDirectGroupInfo(currentUserId, profiles, profilesInChannel, teammateNameDisplay, channel) { + const profilesIds = profilesInChannel[channel.id]; + const gm = {...channel}; + + if (profilesIds) { + return Object.assign(gm, { + display_name: getGroupDisplayNameFromUserIds(profilesIds, profiles, currentUserId, teammateNameDisplay) + }); + } + + const usernames = gm.display_name.split(', '); + const users = Object.values(profiles); + const userIds = []; + usernames.forEach((username) => { + const u = users.find((p) => p.username === username); + if (u) { + userIds.push(u.id); + } + }); + if (usernames.length === userIds.length) { + return Object.assign(gm, { + display_name: getGroupDisplayNameFromUserIds(userIds, profiles, currentUserId, teammateNameDisplay) + }); + } + + return channel; +} diff --git a/share_extension/ios/extension_channel_item.js b/share_extension/common/extension_channel_item.js similarity index 96% rename from share_extension/ios/extension_channel_item.js rename to share_extension/common/extension_channel_item.js index 8d3a4fef4..2f628de46 100644 --- a/share_extension/ios/extension_channel_item.js +++ b/share_extension/common/extension_channel_item.js @@ -13,7 +13,7 @@ import IonIcon from 'react-native-vector-icons/Ionicons'; import {wrapWithPreventDoubleTap} from 'app/utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; -import {DirectChannel, GroupChannel, PublicChannel, PrivateChannel} from 'share_extension/icons/channel_type'; +import {DirectChannel, GroupChannel, PublicChannel, PrivateChannel} from 'share_extension/common/icons/channel_type'; const channelTypes = { D: DirectChannel, @@ -103,8 +103,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { color: theme.centerChannelColor, flex: 1, fontSize: 16, - fontWeight: '600', - lineHeight: 16, paddingRight: 5 }, iconContainer: { diff --git a/share_extension/icons/channel_type.js b/share_extension/common/icons/channel_type.js similarity index 100% rename from share_extension/icons/channel_type.js rename to share_extension/common/icons/channel_type.js diff --git a/share_extension/icons/excel.js b/share_extension/common/icons/excel.js similarity index 100% rename from share_extension/icons/excel.js rename to share_extension/common/icons/excel.js diff --git a/share_extension/icons/generic.js b/share_extension/common/icons/generic.js similarity index 100% rename from share_extension/icons/generic.js rename to share_extension/common/icons/generic.js diff --git a/share_extension/common/icons/index.js b/share_extension/common/icons/index.js new file mode 100644 index 000000000..701c25e61 --- /dev/null +++ b/share_extension/common/icons/index.js @@ -0,0 +1,14 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Excel from './excel'; +import Generic from './generic'; +import Pdf from './pdf'; +import Ppt from './ppt'; +import Zip from './zip'; + +export const ExcelSvg = Excel; +export const GenericSvg = Generic; +export const PdfSvg = Pdf; +export const PptSvg = Ppt; +export const ZipSvg = Zip; diff --git a/share_extension/icons/pdf.js b/share_extension/common/icons/pdf.js similarity index 100% rename from share_extension/icons/pdf.js rename to share_extension/common/icons/pdf.js diff --git a/share_extension/icons/ppt.js b/share_extension/common/icons/ppt.js similarity index 100% rename from share_extension/icons/ppt.js rename to share_extension/common/icons/ppt.js diff --git a/share_extension/icons/zip.js b/share_extension/common/icons/zip.js similarity index 100% rename from share_extension/icons/zip.js rename to share_extension/common/icons/zip.js diff --git a/share_extension/ios/extension_channels.js b/share_extension/ios/extension_channels.js index dca7b0891..13ad5fb94 100644 --- a/share_extension/ios/extension_channels.js +++ b/share_extension/ios/extension_channels.js @@ -20,7 +20,8 @@ import {displayUsername} from 'mattermost-redux/utils/user_utils'; import SearchBar from 'app/components/search_bar'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; -import ExtensionChannelItem from './extension_channel_item'; +import ExtensionChannelItem from 'share_extension/common/extension_channel_item'; + import ExtensionNavBar from './extension_nav_bar'; export default class ExtensionChannels extends PureComponent { diff --git a/share_extension/ios/extension_post.js b/share_extension/ios/extension_post.js index 9a3cbe214..2ce26f2b0 100644 --- a/share_extension/ios/extension_post.js +++ b/share_extension/ios/extension_post.js @@ -27,11 +27,13 @@ import {wrapWithPreventDoubleTap} from 'app/utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import Config from 'assets/config'; -import ExcelSvg from 'share_extension/icons/excel'; -import GenericSvg from 'share_extension/icons/generic'; -import PdfSvg from 'share_extension/icons/pdf'; -import PptSvg from 'share_extension/icons/ppt'; -import ZipSvg from 'share_extension/icons/zip'; +import { + ExcelSvg, + GenericSvg, + PdfSvg, + PptSvg, + ZipSvg +} from 'share_extension/common/icons'; import ExtensionChannels from './extension_channels'; import ExtensionNavBar from './extension_nav_bar'; @@ -278,7 +280,10 @@ export default class ExtensionPost extends PureComponent { return ( - {'Authentication required: Please first login using the app.'} + {formatMessage({ + id: 'mobile.extension.authentication_required', + defaultMessage: 'Authentication required: Please first login using the app.' + })} ); diff --git a/yarn.lock b/yarn.lock index dc427aa99..ea5540f0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -696,6 +696,13 @@ babel-plugin-transform-decorators@^6.24.1: babel-template "^6.24.1" babel-types "^6.24.1" +babel-plugin-transform-define@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-define/-/babel-plugin-transform-define-1.3.0.tgz#94c5f9459c810c738cc7c50cbd44a31829d6f319" + dependencies: + lodash "4.17.4" + traverse "0.6.6" + babel-plugin-transform-do-expressions@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-do-expressions/-/babel-plugin-transform-do-expressions-6.22.0.tgz#28ccaf92812d949c2cd1281f690c8fdc468ae9bb" @@ -1589,6 +1596,10 @@ circular-json@^0.3.1: version "0.3.3" resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" +clamp@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/clamp/-/clamp-1.0.1.tgz#66a0e64011816e37196828fdc8c8c147312c8634" + cli-cursor@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" @@ -3075,7 +3086,7 @@ hoek@4.x.x: version "4.2.0" resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" -hoist-non-react-statics@^2.2.1: +hoist-non-react-statics@^2.2.0, hoist-non-react-statics@^2.2.1: version "2.3.1" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0" @@ -3926,7 +3937,7 @@ lodash.unset@^4.5.2: version "4.5.2" resolved "https://registry.yarnpkg.com/lodash.unset/-/lodash.unset-4.5.2.tgz#370d1d3e85b72a7e1b0cdf2d272121306f23e4ed" -lodash@4.x.x, lodash@^4.0.0, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.1, lodash@^4.8.0: +lodash@4.17.4, lodash@4.x.x, lodash@^4.0.0, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.1, lodash@^4.8.0: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" @@ -5051,10 +5062,26 @@ react-native-device-info@0.13.0: version "0.13.0" resolved "https://registry.yarnpkg.com/react-native-device-info/-/react-native-device-info-0.13.0.tgz#81051eebd3db6959e4f1d2cb0ae8df74105040cf" +react-native-dismiss-keyboard@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/react-native-dismiss-keyboard/-/react-native-dismiss-keyboard-1.0.0.tgz#32886242b3f2317e121f3aeb9b0a585e2b879b49" + react-native-doc-viewer@2.6.8: version "2.6.8" resolved "https://registry.yarnpkg.com/react-native-doc-viewer/-/react-native-doc-viewer-2.6.8.tgz#448b160efa2c20bfee82c06173d4e05c86c3b87b" +react-native-drawer-layout-polyfill@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/react-native-drawer-layout-polyfill/-/react-native-drawer-layout-polyfill-1.3.2.tgz#192c84d7a5a6b8a6d2be2c7daa5e4164518d0cc7" + dependencies: + react-native-drawer-layout "1.3.2" + +react-native-drawer-layout@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/react-native-drawer-layout/-/react-native-drawer-layout-1.3.2.tgz#b9740d7663a1dc4f88a61b9c6d93d2d948ea426e" + dependencies: + react-native-dismiss-keyboard "1.0.0" + react-native-drawer@2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/react-native-drawer/-/react-native-drawer-2.5.0.tgz#022cba5c0516126a9fc9cce3185cb46c644b51c4" @@ -5102,7 +5129,7 @@ react-native-linear-gradient@2.4.0: react-native-local-auth@enahum/react-native-local-auth.git: version "1.5.0" - resolved "https://codeload.github.com/enahum/react-native-local-auth/tar.gz/8307d58bba4f713b6b2a60afb9d6366fcef797d7" + resolved "https://codeload.github.com/enahum/react-native-local-auth/tar.gz/0d6a27a0471ea6db82fac7c65d713dcc9222feba" react-native-mock@0.2.7: version "0.2.7" @@ -5200,6 +5227,12 @@ react-native-svg@6.0.1-rc.0: github-download "^0.5.0" lodash "^4.16.6" +react-native-tab-view@^0.0.74: + version "0.0.74" + resolved "https://registry.yarnpkg.com/react-native-tab-view/-/react-native-tab-view-0.0.74.tgz#62c0c882d9232b461ce181d440d683b4f99d1bd8" + dependencies: + prop-types "^15.6.0" + react-native-tooltip@enahum/react-native-tooltip: version "5.1.1" resolved "https://codeload.github.com/enahum/react-native-tooltip/tar.gz/eb47f93a728813b58c01f23ec84f0b8b1bd70bd0" @@ -5283,6 +5316,18 @@ react-native@0.51.0: xmldoc "^0.4.0" yargs "^9.0.0" +react-navigation@1.0.0-beta.23: + version "1.0.0-beta.23" + resolved "https://registry.yarnpkg.com/react-navigation/-/react-navigation-1.0.0-beta.23.tgz#e666c4985e80d1ba39662838b78ad681c07cc187" + dependencies: + babel-plugin-transform-define "^1.3.0" + clamp "^1.0.1" + hoist-non-react-statics "^2.2.0" + path-to-regexp "^1.7.0" + prop-types "^15.5.10" + react-native-drawer-layout-polyfill "^1.3.2" + react-native-tab-view "^0.0.74" + react-proxy@^1.1.7: version "1.1.8" resolved "https://registry.yarnpkg.com/react-proxy/-/react-proxy-1.1.8.tgz#9dbfd9d927528c3aa9f444e4558c37830ab8c26a" @@ -6505,7 +6550,7 @@ tr46@^1.0.0: dependencies: punycode "^2.1.0" -traverse@^0.6.6: +traverse@0.6.6, traverse@^0.6.6: version "0.6.6" resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137"