Android share extension (#1352)

* Android share extension

* Feedback review

* Feedback review
This commit is contained in:
enahum 2018-01-17 18:17:02 -03:00 committed by GitHub
parent 542f0998b1
commit 926ffe96c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 2254 additions and 26 deletions

View file

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

View file

@ -56,6 +56,21 @@
<activity
android:name="com.reactnativenavigation.controllers.NavigationActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"/>
<activity
android:noHistory="false"
android:name="com.mattermost.share.ShareActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:label="@string/app_name"
android:screenOrientation="portrait"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
// for sharing
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application>
</manifest>

View file

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

View file

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

View file

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

View file

@ -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<Uri> 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();
}
}
}

View file

@ -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<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Arrays.<NativeModule>asList(new ShareModule(reactContext));
}
public List<Class<? extends JavaScriptModule>> createJSModules() {
return Collections.emptyList();
}
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -43,7 +43,7 @@ const setTransforms = [
export default function configureAppStore(initialState) {
const viewsBlackListFilter = createBlacklistFilter(
'views',
['login', 'root']
['extension', 'login', 'root']
);
const typingBlackListFilter = createBlacklistFilter(

View file

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

11
index.android.js Normal file
View file

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

View file

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

View file

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

View file

@ -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 (
<View style={styles.loadingContainer}>
<ActivityIndicator/>
</View>
);
}
return (
<SectionList
sections={sections}
ListHeaderComponent={this.renderSearchBar(styles)}
ItemSeparatorComponent={this.renderItemSeparator}
renderItem={this.renderItem}
renderSectionHeader={this.renderSectionHeader}
keyExtractor={this.keyExtractor}
keyboardShouldPersistTaps='always'
keyboardDismissMode='on-drag'
initialNumToRender={10}
maxToRenderPerBatch={10}
stickySectionHeadersEnabled={true}
scrollEventThrottle={100}
windowSize={5}
/>
);
};
renderItem = ({item}) => {
const {navigation} = this.props;
const {params = {}} = navigation.state;
const {currentChannelId} = params;
return (
<ExtensionChannelItem
channel={item}
currentChannelId={currentChannelId}
onSelectChannel={this.handleSelectChannel}
theme={defaultTheme}
/>
);
};
renderItemSeparator = () => {
const styles = getStyleSheet(defaultTheme);
return (
<View style={styles.separator}/>
);
};
renderSearchBar = (styles) => {
const {formatMessage} = this.context.intl;
return (
<View style={styles.searchContainer}>
<SearchBar
ref='search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={43}
inputStyle={styles.searchBarInput}
placeholderTextColor={changeOpacity(defaultTheme.centerChannelColor, 0.5)}
tintColorSearch={changeOpacity(defaultTheme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(defaultTheme.centerChannelColor, 0.3)}
titleCancelColor={defaultTheme.centerChannelColor}
onChangeText={this.handleSearch}
value={this.state.term}
/>
</View>
);
};
renderSectionHeader = ({section}) => {
const {intl} = this.context;
const styles = getStyleSheet(defaultTheme);
const {
defaultMessage,
id
} = section;
return (
<View style={[styles.titleContainer, {backgroundColor: defaultTheme.centerChannelColor}]}>
<View style={{backgroundColor: changeOpacity(defaultTheme.centerChannelBg, 0.6), justifyContent: 'center'}}>
<Text style={styles.title}>
{intl.formatMessage({id, defaultMessage}).toUpperCase()}
</Text>
</View>
</View>
);
};
render() {
const styles = getStyleSheet(defaultTheme);
return (
<View style={styles.flex}>
{this.renderBody(styles)}
</View>
);
}
}
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
}
};
});

View file

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

View file

@ -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 (
<TouchableHighlight
onPress={onPress}
style={styles.buttonContainer}
underlayColor={changeOpacity(theme.centerChannelColor, 0.2)}
>
<View style={styles.buttonWrapper}>
<Text style={styles.buttonLabel}>
{formatMessage({id: 'mobile.share_extension.channel', defaultMessage: 'Channel'})}
</Text>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.buttonValue}
>
{channelName}
</Text>
</View>
</TouchableHighlight>
);
}
}
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
}
};
});

View file

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

View file

@ -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 = (
<TouchableItem
accessibilityComponentType='button'
accessibilityTraits='button'
borderless={true}
delayPressIn={0}
pressColorAndroid='rgba(0, 0, 0, .32)'
onPress={params.close ? params.close : emptyFunction}
>
<View style={styles.left}>
<MaterialIcon
name='close'
style={styles.closeButton}
/>
</View>
</TouchableItem>
);
const headerRight = (
<TouchableItem
accessibilityComponentType='button'
accessibilityTraits='button'
borderless={true}
delayPressIn={0}
pressColorAndroid='rgba(0, 0, 0, .32)'
onPress={params.post ? params.post : emptyFunction}
>
<View style={styles.left}>
<PaperPlane
color={defalultTheme.sidebarHeaderTextColor}
height={20}
width={20}
/>
</View>
</TouchableItem>
);
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 (
<ScrollView
ref={this.getScrollViewRef}
contentContainerStyle={styles.scrollView}
style={styles.flex}
>
<TextInput
ref={this.getInputRef}
autoCapitalize='sentences'
maxLength={MAX_MESSAGE_LENGTH}
multiline={true}
onBlur={this.handleBlur}
onChangeText={this.handleTextChange}
onFocus={this.handleFocus}
placeholder={formatMessage({id: 'create_post.write', defaultMessage: 'Write a message...'})}
placeholderTextColor={changeOpacity(defalultTheme.centerChannelColor, 0.5)}
style={styles.input}
underlineColorAndroid='transparent'
value={value}
/>
{this.renderFiles()}
</ScrollView>
);
};
renderChannelButton = () => {
const {channelId} = this.state;
return (
<ChannelButton
channelId={channelId}
onPress={this.goToChannels}
theme={defalultTheme}
/>
);
};
renderFiles = () => {
const {files} = this.state;
return files.map((file, index) => {
let component;
if (file.type.startsWith('image')) {
component = (
<View
key={`item-${index}`}
style={styles.imageContainer}
>
<Image
source={{uri: file.fullPath, isStatic: true}}
resizeMode='cover'
style={styles.image}
/>
</View>
);
} else if (file.type.startsWith('video')) {
component = (
<View
key={`item-${index}`}
style={styles.imageContainer}
>
<Video
ref={`video-${index}`}
style={styles.video}
resizeMode='cover'
source={{uri: file.fullPath}}
volume={0}
paused={true}
onLoad={() => this.refs[`video-${index}`].seek(0)}
/>
</View>
);
} else {
let SvgIcon = extensionSvg[file.extension];
if (!SvgIcon) {
SvgIcon = GenericSvg;
}
component = (
<View
key={`item-${index}`}
style={styles.otherContainer}
>
<View style={styles.otherWrapper}>
<View style={styles.fileIcon}>
<SvgIcon
width={19}
height={48}
/>
</View>
</View>
</View>
);
}
return (
<View
style={styles.fileContainer}
key={`item-${index}`}
>
{component}
<Text
ellipsisMode='tail'
numberOfLines={1}
style={styles.filename}
>
{file.filename}
</Text>
</View>
);
});
};
renderTeamButton = () => {
const {teamId} = this.state;
return (
<TeamButton
onPress={this.goToTeams}
teamId={teamId}
theme={defalultTheme}
/>
);
};
render() {
const {formatMessage} = this.context.intl;
const {token, url} = this.props;
const {hasPermission, files} = this.state;
if (hasPermission === false) {
return (
<View
style={styles.flex}
>
<View style={styles.unauthenticatedContainer}>
<Text style={styles.unauthenticated}>
{formatMessage({
id: 'mobile.extension.permission',
defaultMessage: 'Mattermost needs access to the device storage to share files.'
})}
</Text>
</View>
</View>
);
} else if (files.length > 5) {
return (
<View
style={styles.flex}
>
<View style={styles.unauthenticatedContainer}>
<Text style={styles.unauthenticated}>
{formatMessage({
id: 'mobile.extension.file_limit',
defaultMessage: 'Sharing is limited to a maximum of 5 files.'
})}
</Text>
</View>
</View>
);
} else if (token && url) {
return (
<View style={styles.container}>
<View style={styles.wrapper}>
{this.renderBody()}
<View style={styles.flex}>
{this.renderTeamButton()}
{this.renderChannelButton()}
</View>
</View>
</View>
);
}
return (
<View style={styles.unauthenticatedContainer}>
<Text style={styles.unauthenticated}>
{formatMessage({
id: 'mobile.extension.authentication_required',
defaultMessage: 'Authentication required: Please first login using the app.'
})}
</Text>
</View>
);
}
}
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);

View file

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

View file

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

View file

@ -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 (
<TouchableHighlight
onPress={onPress}
style={styles.buttonContainer}
underlayColor={changeOpacity(theme.centerChannelColor, 0.2)}
>
<View style={styles.buttonWrapper}>
<Text style={styles.buttonLabel}>
{formatMessage({id: 'mobile.share_extension.team', defaultMessage: 'Team'})}
</Text>
<Text style={styles.buttonValue}>
{teamName}
</Text>
</View>
</TouchableHighlight>
);
}
}
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
}
};
});

View file

@ -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 (
<View style={styles.separator}/>
);
};
renderItem = ({item}) => {
const {navigation} = this.props;
const {params} = navigation.state;
return (
<TeamItem
currentTeamId={params.currentTeamId}
onSelectTeam={this.handleSelectTeam}
teamId={item}
theme={defaultTheme}
/>
);
};
render() {
const styles = getStyleSheet(defaultTheme);
if (this.state.loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator/>
</View>
);
}
return (
<FlatList
data={this.props.teamIds}
ItemSeparatorComponent={this.renderItemSeparator}
renderItem={this.renderItem}
keyExtractor={this.keyExtractor}
keyboardShouldPersistTaps='always'
keyboardDismissMode='on-drag'
initialNumToRender={10}
maxToRenderPerBatch={10}
scrollEventThrottle={100}
windowSize={5}
/>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
loadingContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center'
},
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1
}
};
});

View file

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

View file

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

View file

@ -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 = (
<View style={styles.checkmarkContainer}>
<IonIcon
name='md-checkmark'
style={styles.checkmark}
/>
</View>
);
}
const icon = (
<View style={styles.iconContainer}>
<Text style={styles.icon}>
{team.display_name.substr(0, 2).toUpperCase()}
</Text>
</View>
);
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={this.onPress}
>
<View style={styles.container}>
<View style={styles.item}>
{icon}
<Text
style={[styles.text]}
ellipsizeMode='tail'
numberOfLines={1}
>
{team.display_name}
</Text>
{current}
</View>
</View>
</TouchableHighlight>
);
}
}
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
}
};
});

View file

@ -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 (
<Provider store={this.store}>
<IntlProvider
locale={locale}
messages={getTranslations(locale)}
>
<Navigation/>
</IntlProvider>
</Provider>
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<View style={styles.unauthenticatedContainer}>
<Text style={styles.unauthenticated}>
{'Authentication required: Please first login using the app.'}
{formatMessage({
id: 'mobile.extension.authentication_required',
defaultMessage: 'Authentication required: Please first login using the app.'
})}
</Text>
</View>
);

View file

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