Refactor Android Share extension (js) (#4893)

* Refactor Android Share extension (js)

* Feedback review
This commit is contained in:
Elias Nahum 2020-10-19 21:39:59 -03:00 committed by GitHub
parent cff81f168e
commit 043b3a0e8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 2135 additions and 2161 deletions

View file

@ -17,9 +17,4 @@ public class ShareActivity extends ReactActivity {
MainApplication app = (MainApplication) this.getApplication();
app.sharedExtensionIsOpened = true;
}
@Override
public void onBackPressed() {
finish();
}
}

View file

@ -743,7 +743,7 @@ export function loadChannelsForTeam(teamId, skipDispatch = false) {
};
}
function loadSidebar(data) {
export function loadSidebar(data) {
return async (dispatch, getState) => {
const state = getState();
const {channels, channelMembers} = data;
@ -752,6 +752,8 @@ function loadSidebar(data) {
if (sidebarActions.length) {
dispatch(batchActions(sidebarActions, 'BATCH_LOAD_SIDEBAR'));
}
return {data: true};
};
}

View file

@ -90,7 +90,6 @@ const ViewTypes = keyMirror({
SET_LAST_UPGRADE_CHECK: null,
ADD_RECENT_EMOJI: null,
EXTENSION_SELECTED_TEAM_ID: null,
ANNOUNCEMENT_BANNER: null,
INCREMENT_EMOJI_PICKER_PAGE: null,

View file

@ -3,4 +3,5 @@
declare module 'gfycat-sdk';
declare module 'remote-redux-devtools';
declare module 'redux-action-buffer';
declare module 'react-intl';
declare module 'react-intl';
declare module 'react-native-local-auth';

View file

@ -1,19 +0,0 @@
// Copyright (c) 2015-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

@ -6,7 +6,6 @@ import {combineReducers} from 'redux';
import announcement from './announcement';
import channel from './channel';
import clientUpgrade from './client_upgrade';
import extension from './extension';
import i18n from './i18n';
import recentEmojis from './recent_emojis';
import root from './root';
@ -22,7 +21,6 @@ export default combineReducers({
announcement,
channel,
clientUpgrade,
extension,
i18n,
recentEmojis,
root,

View file

@ -32,7 +32,7 @@ import {transformSet} from './utils';
* enableThunk - bool - default = true - If true, include the thunk middleware automatically. If false, thunk must be provided as part of additionalMiddleware.
*/
type ReduxStore = {
export type ReduxStore = {
store: redux.Store;
persistor: Persistor;
}
@ -158,7 +158,7 @@ const setTransformer = createTransform(
* @param optionalConfig (optional) persist configuration (applies to tests)
* @param optionalOptions (optional) middleware configuration (applies to tests)
*/
export default function configureStore(storage: any, preloadedState: any = {}, optionalConfig: {}, optionalOptions = {}): ReduxStore {
export default function configureStore(storage: any, preloadedState: any = {}, optionalConfig: any = {}, optionalOptions = {}): ReduxStore {
const defaultOptions: ClientOptions = {
enableBuffer: true,
enableThunk: true,

View file

@ -175,7 +175,7 @@ export const isVideo = (file) => {
* Get the default extension for a MIME type.
*
* @param {string} type
* @return {boolean|string}
* @return {string}
*/
export function getExtensionFromMime(type) {
@ -184,7 +184,7 @@ export function getExtensionFromMime(type) {
}
if (!type || typeof type !== 'string') {
return false;
return undefined;
}
// TODO: use media-typer
@ -194,7 +194,7 @@ export function getExtensionFromMime(type) {
const exts = match && extensions[match[1].toLowerCase()];
if (!exts || !exts.length) {
return false;
return undefined;
}
return exts[0];

View file

@ -19,6 +19,7 @@ module.exports = {
'@i18n': './app/i18n',
'@init': './app/init',
'@mm-redux': './app/mm-redux',
'@share': './share_extension',
'@screens': './app/screens',
'@selectors': './app/selectors',
'@store': './app/store',

View file

@ -53,7 +53,7 @@ const setFontFamily = () => {
};
if (Platform.OS === 'android') {
const ShareExtension = require('share_extension/android').default;
const ShareExtension = require('share_extension/index.tsx').default;
const AppRegistry = require('react-native/Libraries/ReactNative/AppRegistry');
AppRegistry.registerComponent('MattermostShare', () => ShareExtension);
setFontFamily();

54
package-lock.json generated
View file

@ -5911,6 +5911,27 @@
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.36.tgz",
"integrity": "sha512-7TUK/k2/QGpEAv/BCwSHlYu3NXZhQ9ZwBYpzr9tjlPIL2C5BeGhH3DmVavRx3ZNyELX5TLC91JTz/cen6AAtIQ=="
},
"@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
"integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
"dev": true,
"requires": {
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0"
},
"dependencies": {
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dev": true,
"requires": {
"react-is": "^16.7.0"
}
}
}
},
"@types/istanbul-lib-coverage": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
@ -6065,6 +6086,39 @@
"@types/react-native": "*"
}
},
"@types/react-native-video": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.3.tgz",
"integrity": "sha512-5GbhErfUvilPsZNaSCj6+fOM1UyBMN4QYi8Yb0E4tGzXdnGN4B9ILGe7tivys+EXUmgiWWcXlxQdpaDcKZzlqg==",
"dev": true,
"requires": {
"@types/react": "*",
"@types/react-native": "*"
}
},
"@types/react-redux": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.9.tgz",
"integrity": "sha512-mpC0jqxhP4mhmOl3P4ipRsgTgbNofMRXJb08Ms6gekViLj61v1hOZEKWDCyWsdONr6EjEA6ZHXC446wdywDe0w==",
"dev": true,
"requires": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0",
"redux": "^4.0.0"
},
"dependencies": {
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dev": true,
"requires": {
"react-is": "^16.7.0"
}
}
}
},
"@types/shallow-equals": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/shallow-equals/-/shallow-equals-1.0.0.tgz",

View file

@ -104,6 +104,8 @@
"@types/moment-timezone": "0.5.30",
"@types/react": "16.9.48",
"@types/react-native": "0.63.10",
"@types/react-native-video": "5.0.3",
"@types/react-redux": "7.1.9",
"@types/shallow-equals": "1.0.0",
"@typescript-eslint/eslint-plugin": "3.10.1",
"@typescript-eslint/parser": "3.10.1",

View file

@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import {loadSidebar} from '@actions/views/channel';
import {ChannelTypes, RoleTypes} from '@mm-redux/action_types';
import {Client4} from '@mm-redux/client';
import {getRedirectChannelNameForTeam, getChannelsNameMapInTeam} from '@mm-redux/selectors/entities/channels';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
import {Channel, ChannelMembership} from '@mm-redux/types/channels';
import {Role} from '@mm-redux/types/roles';
import {getChannelByName, getChannelsIdForTeam} from '@mm-redux/utils/channel_utils';
const MAX_RETRIES = 3;
interface TeamChannelsData {
channels?: Channel[];
channelMembers?: ChannelMembership[];
roles?: Role[];
sync: boolean;
teamId: string;
teamChannels: string[];
}
export function loadTeamChannels(teamId?: string) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
if (teamId) {
const state = getState();
const currentUserId = getCurrentUserId(state);
const actions = [];
const data: TeamChannelsData = {
sync: true,
teamId,
teamChannels: getChannelsIdForTeam(state, teamId),
};
if (currentUserId) {
for (let i = 0; i <= MAX_RETRIES; i++) {
try {
const [channels, channelMembers] = await Promise.all([ //eslint-disable-line no-await-in-loop
Client4.getMyChannels(teamId, true),
Client4.getMyChannelMembers(teamId),
]);
data.channels = channels;
data.channelMembers = channelMembers;
break;
} catch (err) {
if (i === MAX_RETRIES) {
return {error: err};
}
}
}
if (data.channels) {
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
data,
});
const rolesToLoad = new Set<string>();
const members = data.channelMembers;
if (members) {
for (const member of members) {
for (const role of member.roles.split(' ')) {
rolesToLoad.add(role);
}
}
}
if (rolesToLoad.size > 0) {
try {
data.roles = await Client4.getRolesByNames(Array.from(rolesToLoad));
if (data.roles?.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: data.roles,
});
}
} catch {
//eslint-disable-next-line no-console
console.log('Could not retrieve channel members roles for the user');
}
}
dispatch(batchActions(actions, 'BATCH_LOAD_CHANNELS_FOR_TEAM'));
dispatch(loadSidebar(data));
}
}
}
return {data: true};
};
}
export function getTeamDefaultChannel(teamId: string) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const channelsInTeam = getChannelsNameMapInTeam(state, teamId);
const redirectChannel = getChannelByName(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
return redirectChannel;
};
}

View file

@ -1,27 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getRedirectChannelNameForTeam, getChannelsNameMapInTeam} from '@mm-redux/selectors/entities/channels';
import {getChannelByName} from '@mm-redux/utils/channel_utils';
import {loadChannelsForTeam} from 'app/actions/views/channel';
import {ViewTypes} from 'app/constants';
export function getTeamChannels(teamId) {
return async (dispatch, getState) => {
await dispatch(loadChannelsForTeam(teamId));
const state = getState();
const channelsInTeam = getChannelsNameMapInTeam(state, teamId);
const redirectChannel = getChannelByName(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
return redirectChannel?.id;
};
}
export function extensionSelectTeamId(teamId) {
return {
type: ViewTypes.EXTENSION_SELECTED_TEAM_ID,
data: teamId,
};
}

View file

@ -1,119 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
TouchableHighlight,
Text,
View,
} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {DirectChannel, GroupChannel, PublicChannel, PrivateChannel} from 'share_extension/common/icons/channel_type';
const channelTypes = {
D: DirectChannel,
G: GroupChannel,
O: PublicChannel,
P: PrivateChannel,
};
export default class ExtensionChannelItem extends PureComponent {
static propTypes = {
channel: PropTypes.object.isRequired,
currentChannelId: PropTypes.string.isRequired,
onSelectChannel: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
};
onPress = preventDoubleTap(() => {
const {channel, onSelectChannel} = this.props;
requestAnimationFrame(() => {
onSelectChannel(channel);
});
});
render() {
const {
channel,
currentChannelId,
theme,
} = this.props;
const style = getStyleSheet(theme);
const isCurrent = channel.id === currentChannelId;
let current;
if (isCurrent) {
current = (
<View style={style.checkmarkContainer}>
<CompassIcon
name='check'
style={style.checkmark}
/>
</View>
);
}
const Type = channelTypes[channel.type] || PublicChannel;
const icon = <Type/>;
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={this.onPress}
>
<View style={style.container}>
<View style={style.item}>
{icon}
<Text
style={[style.text]}
ellipsizeMode='tail'
numberOfLines={1}
>
{channel.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: {
marginRight: 5,
},
checkmarkContainer: {
alignItems: 'flex-end',
},
checkmark: {
color: theme.linkColor,
fontSize: 16,
},
};
});

View file

@ -1,284 +0,0 @@
// Copyright (c) 2015-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 {CommonActions as NavigationActions} from '@react-navigation/native';
import {
ActivityIndicator,
SectionList,
Text,
View,
Platform,
} from 'react-native';
import {Preferences} from '@mm-redux/constants';
import SearchBar from 'app/components/search_bar';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import ExtensionChannelItem from './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,
route: PropTypes.object.isRequired,
};
static defaultProps = {
directChannels: [],
privateChannels: [],
publicChannels: [],
};
static contextTypes = {
intl: intlShape,
};
state = {
sections: null,
};
componentDidMount() {
this.buildSections();
}
setSearchBarRef = (ref) => {
this.searchBarRef = ref;
}
buildSections = (term) => {
const sections = [];
let {
directChannels: directFiltered,
privateChannels: privateFiletered,
publicChannels: publicFiltered,
} = this.props;
directFiltered = directFiltered.filter((c) => c.delete_at === 0);
privateFiletered = privateFiletered.filter((c) => c.delete_at === 0);
publicFiltered = publicFiltered.filter((c) => c.delete_at === 0);
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 {navigation, route} = this.props;
const backAction = NavigationActions.goBack();
if (route?.params?.onSelectChannel) {
route.params.onSelectChannel(channel.id);
}
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 (
<React.Fragment>
{this.renderSearchBar(styles)}
<SectionList
style={styles.flex}
sections={sections}
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}
/>
</React.Fragment>
);
};
renderItem = ({item}) => {
const {route} = this.props;
const {currentChannelId} = route.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={this.setSearchBarRef}
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}
autoCapitalize='none'
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,
height: 38,
...Platform.select({
ios: {
paddingLeft: 8,
},
}),
},
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

@ -1,22 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {
getExtensionSortedDirectChannels,
getExtensionSortedPrivateChannels,
getExtensionSortedPublicChannels,
} from 'share_extension/common/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

@ -1,82 +0,0 @@
// Copyright (c) 2015-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

@ -1,16 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getChannel} from '@mm-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

@ -1,795 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {CommonActions as NavigationActions} from '@react-navigation/native';
import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import {
Alert,
NativeModules,
PermissionsAndroid,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import FastImage from 'react-native-fast-image';
import Video from 'react-native-video';
import LocalAuth from 'react-native-local-auth';
import RNFetchBlob from 'rn-fetch-blob';
import {Client4} from '@mm-redux/client';
import {Preferences} from '@mm-redux/constants';
import {getFormattedFileSize, lookupMimeType} from '@mm-redux/utils/file_utils';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import {MAX_FILE_COUNT, MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {getCurrentServerUrl, getAppCredentials} from '@init/credentials';
import {getExtensionFromMime} from '@utils/file';
import {setCSRFFromCookie} from '@utils/security';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import mattermostManaged from 'app/mattermost_managed';
import ChannelButton from './channel_button';
import TeamButton from './team_button';
const defaultTheme = Preferences.THEMES.default;
const iconForExtension = {
csv: 'jumbo-attachment-excel',
pdf: 'jumbo-attachment-pdf',
ppt: 'jumbo-attachment-powerpoint',
pptx: 'jumbo-attachment-powerpoint',
xls: 'jumbo-attachment-excel',
xlsx: 'jumbo-attachment-excel',
zip: 'jumbo-attachment-zip',
generic: 'jumbo-attachment-generic',
};
const ShareExtension = NativeModules.MattermostShare;
const INPUT_HEIGHT = 150;
export default class ExtensionPost extends PureComponent {
static propTypes = {
channelId: PropTypes.string,
channels: PropTypes.object.isRequired,
currentUserId: PropTypes.string.isRequired,
getTeamChannels: PropTypes.func.isRequired,
canUploadFiles: PropTypes.bool.isRequired,
maxFileSize: PropTypes.number.isRequired,
navigation: PropTypes.object.isRequired,
teamId: PropTypes.string.isRequired,
};
static contextTypes = {
intl: intlShape,
};
constructor(props, context) {
super(props, context);
props.navigation.setOptions({
title: context.intl.formatMessage({
id: 'mobile.extension.title',
defaultMessage: 'Share in Mattermost',
}),
});
this.state = {
channelId: props.channelId,
files: [],
hasPermission: null,
teamId: props.teamId,
totalSize: 0,
value: '',
};
}
componentDidMount() {
this.props.navigation.setOptions({
headerLeft: this.leftHeader,
});
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({nativeEvent: true});
}
} else {
await this.showNotSecuredAlert(vendor);
return this.onClose({nativeEvent: true});
}
}
}
} catch (e) {
// do nothing
}
return this.initialize();
};
canPost = (error, text, extensionFiles, calculatedSize) => {
const {maxFileSize} = this.props;
const files = extensionFiles || this.state.files;
const totalSize = calculatedSize || this.state.totalSize;
const filesOK = files.length ? files.length <= MAX_FILE_COUNT : false;
const sizeOK = totalSize ? totalSize <= maxFileSize : false;
if (!error && ((filesOK && sizeOK) || text.length)) {
this.props.navigation.setOptions({headerRight: this.rightHeader});
} else {
this.props.navigation.setOptions({headerRight: null});
}
}
showNotSecuredAlert(vendor) {
const {formatMessage} = this.context.intl;
return new Promise((resolve) => {
Alert.alert(
formatMessage({
id: 'mobile.managed.blocked_by',
defaultMessage: 'Blocked by {vendor}',
}, {vendor}),
formatMessage({
id: 'mobile.managed.not_secured.android',
defaultMessage: 'This device must be secured with a screen lock to use Mattermost.',
}),
[
{
text: formatMessage({
id: 'mobile.managed.settings',
defaultMessage: 'Go to settings',
}),
onPress: () => {
mattermostManaged.goToSecuritySettings();
},
},
{
text: formatMessage({
id: 'mobile.managed.exit',
defaultMessage: 'Exit',
}),
onPress: resolve,
style: 'cancel',
},
],
{onDismiss: resolve},
);
});
}
getAppCredentials = async () => {
try {
const url = await getCurrentServerUrl();
const credentials = await getAppCredentials();
if (credentials) {
const token = credentials.password;
if (url && url !== 'undefined' && token && token !== 'undefined') {
this.token = token;
this.url = url;
Client4.setUrl(url);
Client4.setToken(token);
await setCSRFFromCookie(url);
}
}
} catch (error) {
return null;
}
return null;
};
getInputRef = (ref) => {
this.input = ref;
};
goToChannels = preventDoubleTap(() => {
const {formatMessage} = this.context.intl;
const {navigation} = this.props;
const navigateAction = NavigationActions.navigate({
name: 'Channels',
params: {
title: formatMessage({
id: 'mobile.routes.selectChannel',
defaultMessage: 'Select Channel',
}),
currentChannelId: this.state.channelId,
onSelectChannel: this.handleSelectChannel,
},
});
navigation.dispatch(navigateAction);
});
goToTeams = preventDoubleTap(() => {
const {formatMessage} = this.context.intl;
const {navigation} = this.props;
const navigateAction = NavigationActions.navigate({
name: '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.canPost(null, value);
this.setState({value});
};
initialize = async () => {
await this.getAppCredentials();
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});
};
leftHeader = () => (
<TouchableOpacity
accessibilityComponentType='button'
accessibilityTraits='button'
borderless={true}
delayPressIn={0}
pressColorAndroid='rgba(0, 0, 0, .32)'
onPress={this.onClose}
>
<View style={styles.left}>
<CompassIcon
name='close'
style={styles.closeButton}
/>
</View>
</TouchableOpacity>
);
loadData = async (items) => {
const {getTeamChannels, teamId} = this.props;
if (this.token && this.url) {
const text = [];
const files = [];
let totalSize = 0;
let error;
getTeamChannels(teamId);
for (let i = 0; i < items.length; i++) {
const item = items[i];
switch (item.type) {
case 'text/plain':
text.push(item.value);
break;
default: {
let fileSize = {size: 0};
const fullPath = item.value;
try {
fileSize = await RNFetchBlob.fs.stat(fullPath); // eslint-disable-line no-await-in-loop
} catch (e) {
const {formatMessage} = this.context.intl;
error = formatMessage({
id: 'mobile.extension.file_error',
defaultMessage: 'There was an error reading the file to be shared.\nPlease try again.',
});
break;
}
let filename = fullPath.replace(/^.*[\\/]/, '');
let extension = filename.split('.').pop();
if (extension === filename) {
extension = getExtensionFromMime(item.type);
filename = `${filename}.${extension}`;
}
totalSize += fileSize.size;
files.push({
extension,
filename,
fullPath,
mimeType: item.type || lookupMimeType(filename.toLowerCase()),
size: getFormattedFileSize(fileSize),
type: item.type,
});
break;
}
}
}
const value = text.join('\n');
this.canPost(error, value, files, totalSize);
this.setState({error, files, value, hasPermission: true, totalSize});
}
this.setState({loaded: true});
};
onClose = (data) => {
ShareExtension.close(data?.nativeEvent ? null : data);
};
onPost = () => {
const {channelId, files, value} = this.state;
const {currentUserId} = this.props;
const {formatMessage} = this.context.intl;
if (value.length > MAX_MESSAGE_LENGTH_FALLBACK) {
Alert.alert(
formatMessage({
id: 'mobile.share_extension.too_long_title',
defaultMessage: 'Message is too long',
}),
formatMessage({
id: 'mobile.share_extension.too_long_message',
defaultMessage: 'Character count: {count}/{max}',
}, {
count: value.length,
max: MAX_MESSAGE_LENGTH_FALLBACK,
}),
);
} else {
const data = {
channelId,
currentUserId,
files,
token: this.token,
url: this.url,
value,
};
this.onClose(data);
}
};
renderBody = () => {
const {formatMessage} = this.context.intl;
const {channelId, value} = this.state;
const channel = this.props.channels[channelId];
const channelDisplayName = channel?.display_name || ''; //eslint-disable-line camelcase
return (
<ScrollView
ref={this.getScrollViewRef}
contentContainerStyle={styles.scrollView}
style={styles.flex}
>
<TextInput
ref={this.getInputRef}
autoCapitalize='sentences'
multiline={true}
onBlur={this.handleBlur}
onChangeText={this.handleTextChange}
onFocus={this.handleFocus}
placeholder={formatMessage({id: 'create_post.write', defaultMessage: 'Write to {channelDisplayName}'}, {channelDisplayName})}
placeholderTextColor={changeOpacity(defaultTheme.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={defaultTheme}
/>
);
};
renderErrorMessage = (message) => {
return (
<View
style={styles.flex}
>
<View style={styles.unauthenticatedContainer}>
<Text style={styles.unauthenticated}>
{message}
</Text>
</View>
</View>
);
};
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}
>
<FastImage
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 {
const iconName = iconForExtension[file.extension] || iconForExtension.generic;
component = (
<View
key={`item-${index}`}
style={styles.otherContainer}
>
<View style={styles.otherWrapper}>
<View style={styles.fileIcon}>
<CompassIcon
name={iconName}
size={32}
/>
</View>
</View>
</View>
);
}
return (
<View
style={styles.fileContainer}
key={`item-${index}`}
>
{component}
<Text
ellipsisMode='tail'
numberOfLines={1}
style={styles.filename}
>
{`${file.size} - ${file.filename}`}
</Text>
</View>
);
});
};
rightHeader = () => (
<TouchableOpacity
accessibilityComponentType='button'
accessibilityTraits='button'
borderless={true}
delayPressIn={0}
pressColorAndroid='rgba(0, 0, 0, .32)'
onPress={this.onPost}
disabled={!this.props.canUploadFiles}
>
<View style={styles.left}>
<CompassIcon
name='send'
size={20}
color={defaultTheme.sidebarHeaderTextColor}
/>
</View>
</TouchableOpacity>
);
renderTeamButton = () => {
const {teamId} = this.state;
return (
<TeamButton
onPress={this.goToTeams}
teamId={teamId}
theme={defaultTheme}
/>
);
};
lengthCounterColor = (count) => {
if (count < 0) {
return styles.textTooLong;
}
return styles.textLengthOk;
}
renderMessageLengthRemaining = () => {
const {value} = this.state;
const messageLengthRemaining = MAX_MESSAGE_LENGTH_FALLBACK - value.length;
if (value.length === 0) {
return null;
}
const renderStyle = [styles.messageLengthRemaining, this.lengthCounterColor(messageLengthRemaining)];
return (
<Text style={renderStyle}>
{messageLengthRemaining}
</Text>
);
};
render() {
const {formatMessage} = this.context.intl;
const {maxFileSize} = this.props;
const {error, hasPermission, files, totalSize, loaded, teamId} = this.state;
if (!loaded) {
return (
<Loading/>
);
}
if (error) {
return this.renderErrorMessage(error);
}
if (!teamId && this.token) {
const teamRequired = formatMessage({
id: 'mobile.extension.team_required',
defaultMessage: 'You must belong to a team before you can share files.',
});
return this.renderErrorMessage(teamRequired);
}
if (!this.props.canUploadFiles) {
const uploadsDisabled = formatMessage({
id: 'mobile.file_upload.disabled',
defaultMessage: 'File uploads from mobile are disabled. Please contact your System Admin for more details.',
});
return this.renderErrorMessage(uploadsDisabled);
}
if (this.token && this.url) {
if (hasPermission === false) {
const storage = formatMessage({
id: 'mobile.extension.permission',
defaultMessage: 'Mattermost needs access to the device storage to share files.',
});
return this.renderErrorMessage(storage);
} else if (files.length > MAX_FILE_COUNT) {
const fileCount = formatMessage({
id: 'mobile.extension.file_limit',
defaultMessage: 'Sharing is limited to a maximum of 5 files.',
});
return this.renderErrorMessage(fileCount);
} else if (totalSize > maxFileSize) {
const maxSize = formatMessage({
id: 'mobile.extension.max_file_size',
defaultMessage: 'File attachments shared in Mattermost must be less than {size}.',
}, {size: getFormattedFileSize({size: maxFileSize})});
return this.renderErrorMessage(maxSize);
}
return (
<View style={styles.container}>
<View style={styles.wrapper}>
{this.renderBody()}
<View style={styles.flex}>
{this.renderMessageLengthRemaining()}
{this.renderTeamButton()}
{this.renderChannelButton()}
</View>
</View>
</View>
);
}
const loginNeeded = formatMessage({
id: 'mobile.extension.authentication_required',
defaultMessage: 'Authentication required: Please first login using the app.',
});
return this.renderErrorMessage(loginNeeded);
}
}
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: {
flex: 1,
padding: 15,
},
messageLengthRemaining: {
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 15,
paddingRight: 15,
opacity: 0.5,
},
textLengthOk: {
color: theme.centerChannelColor,
},
textTooLong: {
color: theme.errorTextColor,
},
input: {
flex: 1,
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: {
alignItems: 'center',
height: 48,
justifyContent: 'center',
overflow: 'hidden',
width: 38,
},
};
});
const styles = getStyleSheet(defaultTheme);

View file

@ -1,76 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert, PermissionsAndroid} from 'react-native';
import {shallowWithIntl} from 'test/intl-test-helper';
import ExtensionPost from './extension_post';
jest.spyOn(Alert, 'alert').mockReturnValue(true);
jest.spyOn(PermissionsAndroid, 'check').mockReturnValue(PermissionsAndroid.RESULTS.GRANTED);
jest.mock('@react-navigation/stack/lib/module/views/TouchableItem', () => null);
jest.mock('app/mattermost_managed', () => ({
getConfig: jest.fn().mockReturnValue(false),
}));
const MAX_MESSAGE_LENGTH = 4000;
describe('ExtensionPost', () => {
const baseProps = {
channelId: 'channel-id',
channels: {},
currentUserId: 'current-user-id',
getTeamChannels: jest.fn(),
canUploadFiles: true,
maxFileSize: 1024,
navigation: {
setOptions: jest.fn(),
},
route: {
params: {},
},
teamId: 'team-id',
};
const wrapper = shallowWithIntl(
<ExtensionPost {...baseProps}/>,
);
const instance = wrapper.instance();
instance.renderErrorMessage = jest.fn();
const postMessage = (message) => {
wrapper.setState({value: message});
instance.onPost();
};
test('should show Alert dialog if shared message is longer than maximum length permitted', () => {
const longMessage = 'a'.repeat(MAX_MESSAGE_LENGTH + 1);
postMessage(longMessage);
expect(Alert.alert).toHaveBeenCalledTimes(1);
});
test('should not show Alert dialog if shared message is within maximum length permitted', () => {
const message = 'a'.repeat(MAX_MESSAGE_LENGTH - 1);
postMessage(message);
expect(Alert.alert).not.toHaveBeenCalled();
});
test('should not show Alert dialog if shared message is at maximum length permitted', () => {
const exactLengthMessage = 'a'.repeat(MAX_MESSAGE_LENGTH);
postMessage(exactLengthMessage);
expect(Alert.alert).not.toHaveBeenCalled();
});
test('should render file uploads disabled message when canUploadFiles is false', () => {
wrapper.setState({loaded: true});
wrapper.setProps({canUploadFiles: false});
expect(instance.renderErrorMessage).toHaveBeenCalledWith('File uploads from mobile are disabled. Please contact your System Admin for more details.');
});
});

View file

@ -1,38 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getAllChannels, getCurrentChannel, getDefaultChannel} from '@mm-redux/selectors/entities/channels';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {getConfig, canUploadFilesOnMobile} from '@mm-redux/selectors/entities/general';
import {getTeamChannels} from 'share_extension/android/actions';
import {getAllowedServerMaxFileSize} from 'app/utils/file';
import ExtensionPost from './extension_post';
function mapStateToProps(state) {
const config = getConfig(state);
let channel = getCurrentChannel(state);
if (channel && (channel.delete_at !== 0)) {
channel = getDefaultChannel(state);
}
return {
channelId: channel?.id,
channels: getAllChannels(state),
currentUserId: getCurrentUserId(state),
canUploadFiles: canUploadFilesOnMobile(state),
maxFileSize: getAllowedServerMaxFileSize(config),
teamId: getCurrentTeamId(state),
};
}
const mapDispatchToProps = ({
getTeamChannels,
});
export default connect(mapStateToProps, mapDispatchToProps)(ExtensionPost);

View file

@ -1,16 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTeam} from '@mm-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

@ -1,76 +0,0 @@
// Copyright (c) 2015-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

@ -1,122 +0,0 @@
// Copyright (c) 2015-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 {CommonActions as NavigationActions} from '@react-navigation/native';
import {
ActivityIndicator,
FlatList,
View,
} from 'react-native';
import {Preferences} from '@mm-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,
route: PropTypes.object.isRequired,
teamIds: PropTypes.array,
};
static defaultProps = {
teamIds: [],
};
static contextTypes = {
intl: intlShape,
};
state = {
loading: false,
};
handleSelectTeam = async (teamId) => {
const {actions, navigation, route} = this.props;
const backAction = NavigationActions.goBack();
if (route?.params?.onSelectTeam) {
this.setState({loading: true});
const channelId = await actions.getTeamChannels(teamId);
actions.extensionSelectTeamId(teamId);
route.params.onSelectTeam(teamId, channelId);
}
navigation.dispatch(backAction);
};
keyExtractor = (item) => item;
renderItemSeparator = () => {
const styles = getStyleSheet(defaultTheme);
return (
<View style={styles.separator}/>
);
};
renderItem = ({item}) => {
const {params} = this.props.route;
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

@ -1,28 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {getMySortedTeamIds} from '@mm-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

@ -1,15 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTeam} from '@mm-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

@ -1,116 +0,0 @@
// Copyright (c) 2015-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 CompassIcon from '@components/compass_icon';
import TeamIcon from '@components/team_icon';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@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 = preventDoubleTap(() => {
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}>
<CompassIcon
name='check'
style={styles.checkmark}
/>
</View>
);
}
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={this.onPress}
>
<View style={styles.container}>
<View style={styles.item}>
<TeamIcon
teamId={team.id}
styleContainer={styles.teamIconContainer}
styleText={styles.teamIconText}
styleImage={styles.imageContainer}
/>
<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,
},
teamIconContainer: {
backgroundColor: theme.sidebarBg,
marginRight: 10,
},
teamIconText: {
color: theme.sidebarText,
},
imageContainer: {
backgroundColor: '#ffffff',
},
checkmarkContainer: {
alignItems: 'flex-end',
},
checkmark: {
color: theme.linkColor,
fontSize: 16,
},
};
});

View file

@ -1,93 +0,0 @@
// Copyright (c) 2015-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 {getMyTeams, getMyTeamMembers, selectTeam} from '@mm-redux/actions/teams';
import {selectDefaultTeam} from '@actions/views/select_team';
import {selectDefaultChannel, loadChannelsForTeam} from '@actions/views/channel';
import {getTranslations} from '@i18n';
import {getCurrentLocale} from '@selectors/i18n';
import configureStore from '@store';
import getStorage from '@store/mmkv_adapter';
import Store from '@store/store';
import {waitForHydration} from '@store/utils';
import {extensionSelectTeamId} from './actions';
let Extension;
export default class ShareApp extends PureComponent {
constructor() {
super();
if (!Extension) {
Extension = require('./extension').default;
}
this.state = {init: false};
}
componentDidMount() {
this.mounted = true;
this.initialize();
}
initialize = async () => {
if (Store.redux) {
this.hydrate();
return;
}
getStorage().then(this.hydrate);
};
hydrate = (MMKVStorage) => {
if (MMKVStorage) {
configureStore(MMKVStorage);
}
waitForHydration(Store.redux, async () => {
await this.initTeamAndChannel(Store.redux);
this.setState({init: true});
});
}
initTeamAndChannel = async ({dispatch, getState}) => {
await dispatch(getMyTeams());
await dispatch(getMyTeamMembers());
const {myMembers} = getState().entities.teams;
if (Object.keys(myMembers).length === 0) {
dispatch(selectTeam(''));
dispatch(extensionSelectTeamId(''));
} else {
await dispatch(selectDefaultTeam());
const {currentTeamId} = getState().entities.teams;
await dispatch(loadChannelsForTeam(currentTeamId));
await dispatch(selectDefaultChannel(currentTeamId));
dispatch(extensionSelectTeamId(currentTeamId));
}
}
render() {
if (!this.state.init) {
return null;
}
const locale = getCurrentLocale(Store.redux.getState());
return (
<Provider store={Store.redux}>
<IntlProvider
locale={locale}
messages={getTranslations(locale)}
>
<Extension/>
</IntlProvider>
</Provider>
);
}
}

View file

@ -1,164 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {General} from '@mm-redux/constants';
import {getAllChannels, getChannelsInTeam, getMyChannelMemberships} from '@mm-redux/selectors/entities/channels';
import {getCurrentUser, getUsers} from '@mm-redux/selectors/entities/users';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getLastPostPerChannel} from '@mm-redux/selectors/entities/posts';
import {
getMyPreferences,
getTeammateNameDisplaySetting,
getVisibleTeammate,
getVisibleGroupIds,
} from '@mm-redux/selectors/entities/preferences';
import {
completeDirectChannelDisplayName,
getDirectChannelName,
getGroupDisplayNameFromUserIds,
getUserIdFromChannelName,
isAutoClosed,
sortChannelsByDisplayName,
} from '@mm-redux/utils/channel_utils';
import {createIdsSelector} from '@mm-redux/utils/helpers';
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';
return teamChannelIds.reduce((publicChannels, id) => {
if (!myMembers[id]) {
return publicChannels;
}
const channel = channels[id];
if (channel.type === General.OPEN_CHANNEL) {
publicChannels.push(channel);
}
return publicChannels;
}, []).sort(sortChannelsByDisplayName.bind(null, locale));
},
);
export const getExtensionSortedPrivateChannels = createSelector(
getCurrentUser,
getAllChannels,
getMyChannelMemberships,
getChannelIdsForExtensionTeam,
(currentUser, channels, myMembers, teamChannelIds) => {
if (!currentUser) {
return [];
}
const locale = currentUser.locale || 'en';
return teamChannelIds.reduce((privateChannels, id) => {
if (!myMembers[id]) {
return privateChannels;
}
const channel = channels[id];
if (channel.type === General.PRIVATE_CHANNEL) {
privateChannels.push(channel);
}
return privateChannels;
}, []).sort(sortChannelsByDisplayName.bind(null, locale));
},
);
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, profilesInChannel[id], 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

@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {forwardRef, useCallback, useImperativeHandle, useRef, useState} from 'react';
import {ScrollView, StyleSheet, TextInput} from 'react-native';
import {Preferences} from '@mm-redux/constants';
import {changeOpacity} from '@utils/theme';
import CharsRemaining from './chars_remaining';
import Files from './files';
export interface BodyProps {
canPost: (error?: string, text?: string, extensionFiles?: Array<ShareFileInfo>, calculatedSize?: number) => void;
files: Array<ShareFileInfo>;
initialValue?: string;
placeholder: string;
}
const INPUT_HEIGHT = 150;
const theme = Preferences.THEMES.default;
const Body = forwardRef<BodyRef, BodyProps>(({canPost, files, initialValue, placeholder}: BodyProps, ref) => {
const scrollViewRef = useRef<ScrollView>(null);
const inputRef = useRef<TextInput>(null);
const [value, setValue] = useState(initialValue);
const getValue = () => {
return value?.trim();
};
const handleBlur = useCallback(() => {
inputRef.current?.setNativeProps({
autoScroll: false,
});
}, []);
const handleFocus = useCallback(() => {
inputRef.current?.setNativeProps({
autoScroll: true,
});
}, []);
const handleTextChange = useCallback((text) => {
canPost(undefined, text);
setValue(text);
}, []);
useImperativeHandle(ref, () => ({
getValue,
}));
return (
<>
<ScrollView
contentContainerStyle={styles.scrollView}
ref={scrollViewRef}
style={styles.flex}
>
<TextInput
ref={inputRef}
autoCapitalize='sentences'
autoCompleteType='off'
multiline={true}
onBlur={handleBlur}
onChangeText={handleTextChange}
onFocus={handleFocus}
placeholder={placeholder}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
style={styles.input}
underlineColorAndroid='transparent'
value={value}
/>
<Files files={files}/>
</ScrollView>
<CharsRemaining text={value}/>
</>
);
});
const styles = StyleSheet.create({
flex: {
flex: 1,
},
input: {
flex: 1,
color: theme.centerChannelColor,
fontSize: 17,
height: INPUT_HEIGHT,
marginBottom: 5,
textAlignVertical: 'top',
width: '100%',
},
scrollView: {
flex: 1,
padding: 15,
},
});
Body.displayName = 'Body';
export default Body;

View file

@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {StyleSheet, Text, TouchableHighlight, View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {Preferences} from '@mm-redux/constants';
import type {Channel} from '@mm-redux/types/channels';
import {changeOpacity} from '@utils/theme';
interface ChannelButtonProps {
channel?: Channel | null;
intl: typeof intlShape;
onSelect: (channel?: Channel | null) => void;
teamId?: string;
}
const theme = Preferences.THEMES.default;
const ChannelButton = ({channel, intl, onSelect, teamId}: ChannelButtonProps) => {
const navigation = useNavigation();
const onPress = () => {
navigation.navigate('Channels', {
title: intl.formatMessage({id: 'mobile.routes.selectChannel', defaultMessage: 'Select Channel'}),
currentChannelId: channel?.id,
teamId,
onSelectChannel,
});
};
const onSelectChannel = (c: Channel) => {
onSelect(c);
navigation.goBack();
};
return (
<TouchableHighlight
onPress={onPress}
style={styles.buttonContainer}
underlayColor={changeOpacity(theme.centerChannelColor, 0.2)}
>
<View style={styles.buttonWrapper}>
<FormattedText
defaultMessage='Channel'
id='mobile.share_extension.channel'
style={styles.buttonLabel}
/>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.buttonValue}
>
{channel?.display_name}
</Text>
</View>
</TouchableHighlight>
);
};
const styles = StyleSheet.create({
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,
},
});
export default injectIntl(ChannelButton);

View file

@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode} from 'react';
import {StyleSheet, Text, TouchableHighlight, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {Preferences} from '@mm-redux/constants';
import {Channel} from '@mm-redux/types/channels';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity} from '@utils/theme';
import {DirectChannel, GroupChannel, PublicChannel, PrivateChannel} from '@share/components/channel_type';
interface ChannelItemProps {
onSelect: (channel: Channel) => void;
selected: boolean;
channel: Channel;
}
const theme = Preferences.THEMES.default;
const channelTypes: Record<string, ReactNode> = {
D: DirectChannel,
G: GroupChannel,
O: PublicChannel,
P: PrivateChannel,
};
const ChannelItem = ({onSelect, selected, channel}: ChannelItemProps) => {
const onPress = preventDoubleTap(() => {
onSelect(channel);
});
let current;
if (selected) {
current = (
<View style={styles.checkmarkContainer}>
<CompassIcon
name='check'
style={styles.checkmark}
/>
</View>
);
}
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={onPress}
>
<View style={styles.container}>
<View style={styles.item}>
{channelTypes[channel.type] || PublicChannel}
<Text
style={[styles.text]}
ellipsizeMode='tail'
numberOfLines={1}
>
{channel.display_name}
</Text>
{current}
</View>
</View>
</TouchableHighlight>
);
};
const styles = StyleSheet.create({
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: {
marginRight: 5,
},
checkmarkContainer: {
alignItems: 'flex-end',
},
checkmark: {
color: theme.linkColor,
fontSize: 16,
},
});
export default ChannelItem;

View file

@ -2,16 +2,12 @@
// See LICENSE.txt for license information.
import React from 'react';
import {
View,
} from 'react-native';
import {Preferences} from '@mm-redux/constants';
import {StyleSheet, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {Preferences} from '@mm-redux/constants';
const defaultTheme = Preferences.THEMES.default;
const theme = Preferences.THEMES.default;
export function PublicChannel() {
return (
@ -57,18 +53,14 @@ export function GroupChannel() {
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
height: 16,
marginRight: 5,
width: 16,
},
icon: {
color: theme.centerChannelColor,
fontSize: 16,
},
};
const style = StyleSheet.create({
container: {
height: 16,
marginRight: 5,
width: 16,
},
icon: {
color: theme.centerChannelColor,
fontSize: 16,
},
});
const style = getStyleSheet(defaultTheme);

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, Text, View} from 'react-native';
import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {Preferences} from '@mm-redux/constants';
interface CharsRemainingProps {
text?: string;
}
const theme = Preferences.THEMES.default;
const CharsRemaining = ({text}: CharsRemainingProps) => {
const count = text?.trim().length || 0;
const messageLengthRemaining = `${count}/${MAX_MESSAGE_LENGTH_FALLBACK}`;
const tooLong = MAX_MESSAGE_LENGTH_FALLBACK - count;
const textStyle = tooLong < 0 ? styles.textTooLong : styles.textLengthOk;
const renderStyle = [styles.messageLengthRemaining, textStyle];
return (
<View style={styles.container}>
<Text style={renderStyle}>
{messageLengthRemaining}
</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'flex-end',
top: -10,
},
messageLengthRemaining: {
paddingBottom: 5,
paddingLeft: 15,
paddingRight: 15,
opacity: 0.5,
},
textLengthOk: {
color: theme.centerChannelColor,
},
textTooLong: {
color: theme.errorTextColor,
},
});
export default CharsRemaining;

View file

@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {TouchableOpacity, StyleSheet, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {Preferences} from '@mm-redux/constants';
interface CloseHeaderButtonProps {
onPress: () => void;
}
const theme = Preferences.THEMES.default;
const CloseHeaderButton = ({onPress}: CloseHeaderButtonProps) => {
return (
<TouchableOpacity
accessibilityComponentType='button'
accessibilityTraits='button'
delayPressIn={0}
onPress={onPress}
>
<View style={styles.left}>
<CompassIcon
name='arrow-left'
style={styles.closeButton}
/>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
left: {
alignItems: 'center',
height: 50,
justifyContent: 'center',
width: 50,
},
closeButton: {
color: theme.sidebarHeaderTextColor,
fontSize: 25,
},
});
export default CloseHeaderButton;

View file

@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, Text, View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {Preferences} from '@mm-redux/constants';
interface ShareErrorProps {
message?: string;
}
const theme = Preferences.THEMES.default;
const ShareError = ({message}: ShareErrorProps) => {
let error;
if (message) {
error = (
<Text style={styles.unauthenticated}>
{message}
</Text>
);
} else {
error = (
<FormattedText
defaultMessage='Authentication required: Please first login using the app.'
id='mobile.extension.authentication_required'
style={styles.unauthenticated}
/>
);
}
return (
<View
style={styles.flex}
>
<View style={styles.container}>
{error}
</View>
</View>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
container: {
alignItems: 'center',
flex: 1,
paddingHorizontal: 20,
paddingTop: 35,
},
unauthenticated: {
color: theme.errorTextColor,
fontSize: 14,
},
});
export default ShareError;

View file

@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, Text, View} from 'react-native';
import {Preferences} from '@mm-redux/constants';
import {changeOpacity} from '@utils/theme';
import ImageFile from './image_file';
import OtherFile from './other_file';
import VideoFile from './video_file';
interface FilesProps {
files: Array<ShareFileInfo>;
}
const theme = Preferences.THEMES.default;
const Files = ({files}: FilesProps) => {
const elements = files.map((file, index) => {
let component;
const key = `item-${index}`;
if (file.type.startsWith('image')) {
component = (
<ImageFile
key={key}
uri={file.fullPath}
/>
);
} else if (file.type.startsWith('video')) {
component = (
<VideoFile
key={key}
uri={file.fullPath}
/>
);
} else {
component = (
<OtherFile
extension={file.extension}
key={key}
/>
);
}
return (
<View
style={styles.container}
key={key}
>
{component}
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.filename}
>
{`${file.size} - ${file.filename}`}
</Text>
</View>
);
});
return (
<>
{elements}
</>
);
};
const styles = StyleSheet.create({
container: {
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,
},
});
export default Files;

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import FastImage from 'react-native-fast-image';
interface ImageFileProps {
uri: string;
}
const ImageFile = ({uri}: ImageFileProps) => {
return (
<View style={styles.container}>
<FastImage
source={{uri}}
resizeMode='cover'
style={styles.image}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
height: 48,
marginRight: 10,
width: 48,
overflow: 'hidden',
},
image: {
alignItems: 'center',
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
height: 46,
justifyContent: 'center',
overflow: 'hidden',
width: 48,
},
});
export default ImageFile;

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {Preferences} from '@mm-redux/constants';
import {changeOpacity} from '@utils/theme';
interface OtherFileProps {
extension?: string;
}
const theme = Preferences.THEMES.default;
const iconForExtension: Record<string, string> = {
csv: 'jumbo-attachment-excel',
pdf: 'jumbo-attachment-pdf',
ppt: 'jumbo-attachment-powerpoint',
pptx: 'jumbo-attachment-powerpoint',
xls: 'jumbo-attachment-excel',
xlsx: 'jumbo-attachment-excel',
zip: 'jumbo-attachment-zip',
generic: 'jumbo-attachment-generic',
};
const OtherFile = ({extension}: OtherFileProps) => {
let iconName = iconForExtension.generic;
if (extension && iconForExtension[extension]) {
iconName = iconForExtension[extension];
}
return (
<View style={styles.container}>
<View style={styles.wrapper}>
<View style={styles.icon}>
<CompassIcon
name={iconName}
size={32}
/>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
height: 48,
marginRight: 10,
paddingVertical: 10,
width: 48,
},
icon: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
},
wrapper: {
borderRightWidth: 1,
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2),
flex: 1,
},
});
export default OtherFile;

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {TouchableOpacity, StyleSheet, View} from 'react-native';
import {useSelector} from 'react-redux';
import CompassIcon from '@components/compass_icon';
import {Preferences} from '@mm-redux/constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {canUploadFilesOnMobile} from '@mm-redux/selectors/entities/general';
import {preventDoubleTap} from '@utils/tap';
export const SHARE_EXTENSION_POST_EVENT = 'share-extesion-post-event';
const theme = Preferences.THEMES.default;
const PostHeaderButton = () => {
const canUploadFiles = useSelector(canUploadFilesOnMobile);
const onPress = preventDoubleTap(() => {
EventEmitter.emit(SHARE_EXTENSION_POST_EVENT);
});
return (
<TouchableOpacity
accessibilityComponentType='button'
accessibilityTraits='button'
delayPressIn={0}
onPress={onPress}
disabled={!canUploadFiles}
>
<View style={styles.left}>
<CompassIcon
color={theme.sidebarHeaderTextColor}
name='send'
size={20}
/>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
left: {
alignItems: 'center',
height: 50,
justifyContent: 'center',
width: 50,
},
});
export default PostHeaderButton;

View file

@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {StyleSheet, Text, TouchableHighlight, View} from 'react-native';
import {useDispatch, useStore} from 'react-redux';
import FormattedText from '@components/formatted_text';
import {getMyTeams} from '@mm-redux/actions/teams';
import {Preferences} from '@mm-redux/constants';
import {DispatchFunc} from '@mm-redux/types/actions';
import type {Channel} from '@mm-redux/types/channels';
import type {Team} from '@mm-redux/types/teams';
import {changeOpacity} from '@utils/theme';
import {loadTeamChannels, getTeamDefaultChannel} from '@share/actions';
interface TeamButtonProps {
intl: typeof intlShape;
onSelect: (team: Team, channel?: Channel | null) => void;
team?: Team | null;
}
const theme = Preferences.THEMES.default;
const TeamButton = ({intl, onSelect, team}: TeamButtonProps) => {
const store = useStore();
const dispatch = useDispatch();
const navigation = useNavigation();
const onPress = () => {
dispatch(getMyTeams());
navigation.navigate('Teams', {
title: intl.formatMessage({id: 'mobile.routes.selectTeam', defaultMessage: 'Select Team'}),
currentTeamId: team?.id,
onSelectTeam,
});
};
const onSelectTeam = async (t: Team) => {
const loadChannels = loadTeamChannels(t.id);
const getChannel = getTeamDefaultChannel(t.id);
await loadChannels(dispatch as DispatchFunc, store.getState);
const defaultChannel = await getChannel(dispatch as DispatchFunc, store.getState);
onSelect(t, defaultChannel);
navigation.goBack();
};
return (
<TouchableHighlight
onPress={onPress}
style={styles.buttonContainer}
underlayColor={changeOpacity(theme.centerChannelColor, 0.2)}
>
<View style={styles.buttonWrapper}>
<FormattedText
defaultMessage='Team'
id='mobile.share_extension.team'
style={styles.buttonLabel}
/>
<Text style={styles.buttonValue}>
{team?.display_name}
</Text>
</View>
</TouchableHighlight>
);
};
const styles = StyleSheet.create({
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,
},
});
export default injectIntl(TeamButton);

View file

@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, Text, TouchableHighlight, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TeamIcon from '@components/team_icon';
import {Preferences} from '@mm-redux/constants';
import {Team} from '@mm-redux/types/teams';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity} from '@utils/theme';
interface TeamItemProps {
onSelect: (team: Team) => void;
selected: boolean;
team: Team;
}
const theme = Preferences.THEMES.default;
const TeamItem = ({onSelect, selected, team}: TeamItemProps) => {
const onPress = preventDoubleTap(() => {
onSelect(team);
});
let current;
if (selected) {
current = (
<View style={styles.checkmarkContainer}>
<CompassIcon
name='check'
style={styles.checkmark}
/>
</View>
);
}
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={onPress}
>
<View style={styles.container}>
<View style={styles.item}>
<TeamIcon
teamId={team.id}
styleContainer={styles.teamIconContainer}
styleText={styles.teamIconText}
styleImage={styles.imageContainer}
/>
<Text
style={styles.text}
ellipsizeMode='tail'
numberOfLines={1}
>
{team.display_name}
</Text>
{current}
</View>
</View>
</TouchableHighlight>
);
};
const styles = StyleSheet.create({
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,
},
teamIconContainer: {
backgroundColor: theme.sidebarBg,
marginRight: 10,
},
teamIconText: {
color: theme.sidebarText,
},
imageContainer: {
backgroundColor: '#ffffff',
},
checkmarkContainer: {
alignItems: 'flex-end',
},
checkmark: {
color: theme.linkColor,
fontSize: 16,
},
});
export default TeamItem;

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import Video from 'react-native-video';
interface VideoFileProps {
uri: string;
}
const VideoFile = ({uri}: VideoFileProps) => {
return (
<View style={styles.container}>
<Video
style={styles.video}
resizeMode='cover'
source={{uri}}
volume={0}
paused={false}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
height: 48,
marginRight: 10,
width: 48,
overflow: 'hidden',
},
video: {
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
alignItems: 'center',
height: 46,
justifyContent: 'center',
overflow: 'hidden',
width: 48,
},
});
export default VideoFile;

55
share_extension/index.tsx Normal file
View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {Provider} from 'react-redux';
import {IntlProvider} from 'react-intl';
import {General} from '@mm-redux/constants';
import {getTranslations} from '@i18n';
import {getCurrentLocale} from '@selectors/i18n';
import configureStore from '@store';
import getStorage from '@store/mmkv_adapter';
import Store from '@store/store';
import {waitForHydration} from '@store/utils';
import Extension from './screens/extension';
const ShareExtension = () => {
const [init, setInit] = useState(false);
const hydrate = useCallback((mmkvStore?: any) => {
if (mmkvStore) {
configureStore(mmkvStore);
}
waitForHydration(Store.redux, async () => {
setInit(true);
});
}, []);
useEffect(() => {
if (Store.redux) {
hydrate();
} else {
getStorage().then(hydrate);
}
}, []);
if (!init) {
return null;
}
const locale = getCurrentLocale(Store.redux!.getState()) || General.DEFAULT_LOCALE;
return (
<Provider store={Store.redux!}>
<IntlProvider
locale={locale}
messages={getTranslations(locale)}
>
<Extension/>
</IntlProvider>
</Provider>
);
};
export default ShareExtension;

View file

@ -0,0 +1,229 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RouteProp, useFocusEffect, useNavigation, useRoute} from '@react-navigation/native';
import React, {useCallback, useEffect, useLayoutEffect, useState} from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {BackHandler, SectionList, SectionListData, SectionListRenderItemInfo, StyleSheet, View} from 'react-native';
import {useSelector} from 'react-redux';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import SearchBar from '@components/search_bar';
import {Preferences} from '@mm-redux/constants';
import type {Channel} from '@mm-redux/types/channels';
import {throttle} from '@utils/general';
import {changeOpacity} from '@utils/theme';
import ChannelItem from '@share/components/channel_item';
import {getExtensionSortedDirectChannels, getExtensionSortedPrivateChannels, getExtensionSortedPublicChannels} from '@share/selectors';
import {GlobalState} from '@mm-redux/types/store';
interface ChannnelListProps {
intl: typeof intlShape;
}
interface SectionData {
defaultMessage: string;
id: string;
data: Channel[];
}
type ChannnelListParams = {
Channels: {
currentChannelId?: string;
onSelectChannel: (channel: Channel) => void;
teamId: string;
title: string;
}
}
type SectionDataHeader = (info: {section: SectionListData<Channel>}) => React.ReactElement | null;
export type ChannelListRoute = RouteProp<ChannnelListParams, 'Channels'>;
const theme = Preferences.THEMES.default;
const ChannelList = ({intl}: ChannnelListProps) => {
const navigation = useNavigation();
const route = useRoute<ChannelListRoute>();
const {currentChannelId, onSelectChannel, teamId, title} = route.params;
const directChannels = useSelector(getExtensionSortedDirectChannels);
const privateChannels = useSelector((state: GlobalState) => getExtensionSortedPrivateChannels(state, teamId));
const publicChannels = useSelector((state: GlobalState) => getExtensionSortedPublicChannels(state, teamId));
const [sections, setSections] = useState<SectionData[]>();
const [term, setTerm] = useState<string>();
const handleSearch = useCallback((text) => {
throttle(setTerm(text));
}, []);
const keyExtractor = (item: Channel) => item?.id;
const renderItemSeparator = () => (<View style={styles.separator}/>);
const renderSectionHeader: SectionDataHeader = ({section}) => (
<View style={[styles.titleContainer, {backgroundColor: theme.centerChannelColor}]}>
<View style={{backgroundColor: changeOpacity(theme.centerChannelBg, 0.6), justifyContent: 'center'}}>
<FormattedText
defaultMessage={section.defaultMessage}
id={section.id}
style={styles.title}
/>
</View>
</View>
);
const renderItem = ({item}: SectionListRenderItemInfo<Channel>) => (
<ChannelItem
channel={item}
onSelect={onSelectChannel}
selected={item.id === currentChannelId}
/>
);
useLayoutEffect(() => {
navigation.setOptions({
title,
});
}, [navigation]);
useEffect(() => {
const sectionsArray = [];
let directFiltered;
let privateFiletered;
let publicFiltered;
if (term) {
directFiltered = directChannels.filter((c: Channel) => c.delete_at === 0 && c.display_name.toLowerCase().includes(term.toLowerCase()));
privateFiletered = privateChannels.filter((c: Channel) => c.delete_at === 0 && c.display_name.toLowerCase().includes(term.toLowerCase()));
publicFiltered = publicChannels.filter((c: Channel) => c.delete_at === 0 && c.display_name.toLowerCase().includes(term.toLowerCase()));
} else {
directFiltered = directChannels.filter((c: Channel) => c.delete_at === 0);
privateFiletered = privateChannels.filter((c: Channel) => c.delete_at === 0);
publicFiltered = publicChannels.filter((c: Channel) => c.delete_at === 0);
}
if (publicFiltered.length) {
sectionsArray.push({
id: 'sidebar.channels',
defaultMessage: 'PUBLIC CHANNELS',
data: publicFiltered,
});
}
if (privateFiletered.length) {
sectionsArray.push({
id: 'sidebar.pg',
defaultMessage: 'PRIVATE CHANNELS',
data: privateFiletered,
});
}
if (directFiltered.length) {
sectionsArray.push({
id: 'sidebar.direct',
defaultMessage: 'DIRECT MESSAGES',
data: directFiltered,
});
}
setSections(sectionsArray);
}, [term]);
useFocusEffect(
useCallback(() => {
const onBackPress = () => {
navigation.goBack();
return true;
};
BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => BackHandler.removeEventListener('hardwareBackPress', onBackPress);
}, []),
);
if (!sections) {
return <Loading/>;
}
return (
<View style={styles.flex}>
<View style={styles.searchContainer}>
<SearchBar
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={43}
inputStyle={styles.searchBarInput}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.3)}
titleCancelColor={theme.centerChannelColor}
onChangeText={handleSearch}
autoCapitalize='none'
value={term}
/>
</View>
<SectionList
style={styles.flex}
sections={sections}
ItemSeparatorComponent={renderItemSeparator}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
keyExtractor={keyExtractor}
keyboardShouldPersistTaps='always'
keyboardDismissMode='on-drag'
initialNumToRender={10}
maxToRenderPerBatch={10}
stickySectionHeadersEnabled={true}
scrollEventThrottle={100}
windowSize={5}
/>
</View>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
},
loadingContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
searchContainer: {
paddingBottom: 2,
height: 38,
},
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,
},
});
export default injectIntl(ChannelList);

View file

@ -2,26 +2,35 @@
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {Alert, NativeModules} from 'react-native';
import {Alert, NativeModules, View} from 'react-native';
import {intlShape} from 'react-intl';
import {captureException, initializeSentry, LOGGER_EXTENSION} from 'app/utils/sentry';
import FormattedText from '@components/formatted_text';
import {captureException, initializeSentry, LOGGER_EXTENSION} from '@utils/sentry';
import Navigation from './navigation';
const ShareExtension = NativeModules.MattermostShare;
const NativeShareExtension = NativeModules.MattermostShare;
export default class ShareApp extends PureComponent {
export default class ShareExtension extends PureComponent {
static contextTypes = {
intl: intlShape,
};
constructor(props) {
super(props);
static getDerivedStateFromError(error: Error) {
// Update state so the next render will show the fallback UI.
return {hasError: error};
}
state = {
hasError: undefined,
};
componentDidMount() {
initializeSentry();
}
componentDidCatch(error) {
componentDidCatch(error: Error) {
const {intl, store} = this.context;
const {formatMessage} = intl;
@ -48,9 +57,20 @@ export default class ShareApp extends PureComponent {
);
}
close = () => ShareExtension.close(null)
close = () => NativeShareExtension.close(null)
render() {
if (this.state.hasError) {
return (
<View>
<FormattedText
defaultMessage='Something went wrong'
id='mobile.failed_network_action.teams_title'
/>
</View>
);
}
return <Navigation/>;
}
}

View file

@ -1,17 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import React from 'react';
import {createStackNavigator} from '@react-navigation/stack';
import {Preferences} from '@mm-redux/constants';
import ExtensionChannels from './extension_channels';
import ExtensionPost from './extension_post';
import ExtensionTeams from './extension_teams';
import Channels from './channel_list';
import Share from './share';
import Teams from './team_list';
const theme = Preferences.THEMES.default;
const Stack = createStackNavigator();
const defaultNavigationOptions = {
headerStyle: {
@ -27,6 +28,7 @@ const defaultNavigationOptions = {
margin: 0,
},
headerTintColor: theme.sidebarHeaderTextColor,
headerTopInsetEnabled: false,
};
function RootStack() {
@ -37,15 +39,15 @@ function RootStack() {
>
<Stack.Screen
name='Post'
component={ExtensionPost}
component={Share}
/>
<Stack.Screen
name='Teams'
component={ExtensionTeams}
component={Teams}
/>
<Stack.Screen
name='Channels'
component={ExtensionChannels}
component={Channels}
/>
</Stack.Navigator>
);

View file

@ -0,0 +1,230 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useFocusEffect, useNavigation} from '@react-navigation/native';
import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {Alert, BackHandler, NativeModules, StyleSheet, View} from 'react-native';
import {useSelector} from 'react-redux';
import {MAX_FILE_COUNT, MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {Client4} from '@mm-redux/client';
import {Preferences} from '@mm-redux/constants';
import {getCurrentChannel} from '@mm-redux/selectors/entities/channels';
import {getConfig, canUploadFilesOnMobile} from '@mm-redux/selectors/entities/general';
import {getCurrentTeam} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {Channel} from '@mm-redux/types/channels';
import type {Team} from '@mm-redux/types/teams';
import {getAllowedServerMaxFileSize} from '@utils/file';
import {changeOpacity} from '@utils/theme';
import ChannelButton from '@share/components/channel_button';
import CloseHeaderButton from '@share/components/close_header_button';
import PostHeaderButton, {SHARE_EXTENSION_POST_EVENT} from '@share/components/post_header_button';
import Body from '@share/components/body';
import TeamButton from '@share/components/team_button';
import {isAuthorized, getErrorElement, getSharedItems, permissionEnabled} from '@share/utils';
interface ShareProps {
intl: typeof intlShape;
}
const ShareExtension = NativeModules.MattermostShare;
const theme = Preferences.THEMES.default;
const initialState: ShareState = {
loading: true,
files: [],
};
const leftButton = () => (
<CloseHeaderButton
onPress={() => ShareExtension.close(null)}
/>
);
const rightButton = () => (<PostHeaderButton/>);
const Share = ({intl}: ShareProps) => {
const navigation = useNavigation();
const canUploadFiles = useSelector(canUploadFilesOnMobile);
const config = useSelector(getConfig);
const currentTeam = useSelector(getCurrentTeam);
const currentChannel = useSelector(getCurrentChannel);
const currentUserId = useSelector(getCurrentUserId);
const shareBodyRef = useRef<BodyRef>(null);
const [state, setState] = useState<ShareState>(initialState);
const [team, setTeam] = useState<Team|undefined|null>(currentTeam);
const [channel, setChannel] = useState<Channel|undefined|null>(currentChannel);
const maxFileSize = getAllowedServerMaxFileSize(config);
const showPostButton = (error?: string, text?: string, extensionFiles?: Array<ShareFileInfo>, calculatedSize?: number) => {
const files = extensionFiles || state.files;
const totalSize = calculatedSize || state.totalSize;
const filesOK = files.length ? files.length <= MAX_FILE_COUNT : false;
const sizeOK = totalSize ? totalSize <= maxFileSize : false;
if ((!error && ((filesOK && sizeOK) || text?.length)) && team?.id && channel?.id) {
navigation.setOptions({headerRight: rightButton});
} else {
navigation.setOptions({headerRight: null});
}
};
const loadData = async (authorized: boolean, hasPermission: boolean) => {
const newState: ShareState = {
authorized,
hasPermission,
loading: false,
files: [],
};
if (authorized && hasPermission) {
const data: Array<ShareItem> = await ShareExtension.data();
const {error, files, value, totalSize} = await getSharedItems(data, intl);
newState.error = error;
newState.files = files;
newState.totalSize = totalSize;
newState.value = value;
}
showPostButton(newState.error, newState.value, newState.files, newState.totalSize);
setState(newState);
};
const postMessage = () => {
const {files} = state;
const value = shareBodyRef.current?.getValue();
const {formatMessage} = intl;
const messageLength = value?.length || 0;
if (messageLength > MAX_MESSAGE_LENGTH_FALLBACK) {
Alert.alert(
formatMessage({
id: 'mobile.share_extension.too_long_title',
defaultMessage: 'Message is too long',
}),
formatMessage({
id: 'mobile.share_extension.too_long_message',
defaultMessage: 'Character count: {count}/{max}',
}, {
count: messageLength,
max: MAX_MESSAGE_LENGTH_FALLBACK,
}),
);
} else {
const data = {
channelId: channel?.id,
currentUserId,
files,
token: Client4.getToken(),
url: Client4.getUrl(),
value,
};
ShareExtension.close(data);
}
};
const selectChannel = (c: Channel) => {
setChannel(c);
};
const selectTeam = (t: Team, c?: Channel | null) => {
setTeam(t);
setChannel(c);
};
useLayoutEffect(() => {
navigation.setOptions({
headerLeft: leftButton,
title: intl.formatMessage({
id: 'mobile.extension.title',
defaultMessage: 'Share in Mattermost',
}),
});
}, [navigation]);
useEffect(() => {
isAuthorized(intl).then(async (authorized) => {
const hasPermission = await permissionEnabled();
loadData(authorized, hasPermission);
});
}, []);
useEffect(() => {
EventEmitter.on(SHARE_EXTENSION_POST_EVENT, postMessage);
return () => EventEmitter.off(SHARE_EXTENSION_POST_EVENT, postMessage);
}, [channel, state, team]);
useFocusEffect(
useCallback(() => {
const onBackPress = () => {
ShareExtension.close(null);
return true;
};
BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => BackHandler.removeEventListener('hardwareBackPress', onBackPress);
}, []),
);
const errorElement = getErrorElement(state, canUploadFiles, maxFileSize, team, intl);
if (errorElement) {
return errorElement;
}
const placeholder = intl.formatMessage({
id: 'create_post.write',
defaultMessage: 'Write to {channelDisplayName}',
},
{
channelDisplayName: channel?.display_name,
});
return (
<View style={styles.container}>
<View style={styles.wrapper}>
<Body
canPost={showPostButton}
files={state.files}
initialValue={state.value}
placeholder={placeholder}
ref={shareBodyRef}
/>
<View style={styles.flex}>
<TeamButton
onSelect={selectTeam}
team={team}
/>
<ChannelButton
channel={channel}
onSelect={selectChannel}
teamId={team?.id}
/>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
container: {
flex: 1,
backgroundColor: theme.centerChannelBg,
},
wrapper: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.05),
flex: 1,
},
});
export default injectIntl(Share);

View file

@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RouteProp, useFocusEffect, useNavigation, useRoute} from '@react-navigation/native';
import React, {useCallback, useLayoutEffect} from 'react';
import {BackHandler, FlatList, StyleSheet, View} from 'react-native';
import {useSelector} from 'react-redux';
import Loading from '@components/loading';
import {Preferences} from '@mm-redux/constants';
import {getMyTeams} from '@mm-redux/selectors/entities/teams';
import type {Team} from '@mm-redux/types/teams';
import {sortTeamsWithLocale} from '@mm-redux/utils/team_utils';
import {getCurrentLocale} from '@selectors/i18n';
import {changeOpacity} from '@utils/theme';
import TeamItem from '@share/components/team_item';
type TeamListParams = {
Teams: {
currentTeamId?: string;
onSelectTeam: (team: Team) => void;
title: string;
}
}
type TeamListRoute = RouteProp<TeamListParams, 'Teams'>;
type ListItem = (info: {item: Team}) => React.ReactElement;
const theme = Preferences.THEMES.default;
const TeamList = () => {
const navigation = useNavigation();
const route = useRoute<TeamListRoute>();
const {currentTeamId, onSelectTeam, title} = route.params;
const locale: string = useSelector(getCurrentLocale);
const teams = useSelector(getMyTeams);
teams.sort(sortTeamsWithLocale(locale)).map((t) => t.display_name);
const keyExtractor = (item: Team) => item?.id;
const renderItemSeparator = () => (<View style={styles.separator}/>);
const renderItem: ListItem = ({item}) => (
<TeamItem
selected={item.id === currentTeamId}
onSelect={onSelectTeam}
team={item}
/>
);
useFocusEffect(
useCallback(() => {
const onBackPress = () => {
navigation.goBack();
return true;
};
BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => BackHandler.removeEventListener('hardwareBackPress', onBackPress);
}, []),
);
useLayoutEffect(() => {
navigation.setOptions({
title,
});
}, [navigation]);
if (!teams.length) {
return <Loading/>;
}
return (
<FlatList
data={teams}
ItemSeparatorComponent={renderItemSeparator}
renderItem={renderItem}
keyExtractor={keyExtractor}
keyboardShouldPersistTaps='always'
keyboardDismissMode='on-drag'
initialNumToRender={10}
maxToRenderPerBatch={10}
scrollEventThrottle={100}
windowSize={5}
/>
);
};
const styles = StyleSheet.create({
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
},
});
export default TeamList;

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {General} from '@mm-redux/constants';
import {getAllChannels, getMyChannelMemberships} from '@mm-redux/selectors/entities/channels';
import {getCurrentUser, getUsers, getUserIdsInChannels} from '@mm-redux/selectors/entities/users';
import {getTeammateNameDisplaySetting, getVisibleTeammate, getVisibleGroupIds} from '@mm-redux/selectors/entities/preferences';
import {completeDirectChannelDisplayName, getDirectChannelName, sortChannelsByDisplayName} from '@mm-redux/utils/channel_utils';
import {Channel} from '@mm-redux/types/channels';
import {GlobalState} from '@mm-redux/types/store';
export const getExtensionSortedPublicChannels = createSelector(
getCurrentUser,
getAllChannels,
getMyChannelMemberships,
(state: GlobalState, teamId: string) => teamId,
(user, channels, memberships, teamId) => {
if (!user) {
return [];
}
const locale = user.locale || General.DEFAULT_LOCALE;
const publicChannels = Object.values(channels).filter((c) => c.team_id === teamId && !c.delete_at && c.type === General.OPEN_CHANNEL && memberships[c.id]);
return publicChannels.sort(sortChannelsByDisplayName.bind(null, locale));
},
);
export const getExtensionSortedPrivateChannels = createSelector(
getCurrentUser,
getAllChannels,
getMyChannelMemberships,
(state: GlobalState, teamId: string) => teamId,
(user, channels, memberships, teamId) => {
if (!user) {
return [];
}
const locale = user.locale || General.DEFAULT_LOCALE;
const privateChannels = Object.values(channels).filter((c) => c.team_id === teamId && !c.delete_at && c.type === General.PRIVATE_CHANNEL && memberships[c.id]);
return privateChannels.sort(sortChannelsByDisplayName.bind(null, locale));
},
);
export const getExtensionSortedDirectChannels = createSelector(
getCurrentUser,
getUsers,
getUserIdsInChannels,
getAllChannels,
getVisibleTeammate,
getVisibleGroupIds,
getTeammateNameDisplaySetting,
(user, profiles, profilesInChannel, allChannels, teammates, groupIds, settings) => {
if (!user) {
return [];
}
const locale = user.locale || General.DEFAULT_LOCALE;
const channels = Object.values(allChannels).filter((c) => !c.team_id);
const gms = groupIds.map((id) => {
const channel = allChannels[id];
const userIdsInChannel = new Set(profilesInChannel[channel.id]);
return completeDirectChannelDisplayName(user.id, profiles, userIdsInChannel, settings || '', channel);
});
const dms = teammates.reduce((acc, otherUserId) => {
const channelName = getDirectChannelName(user.id, otherUserId);
const channel = channels.find((c) => c.name === channelName); //eslint-disable-line max-nested-callbacks
const otherUser = profiles[otherUserId];
if (channel && otherUser?.delete_at === 0) {
const userIdsInChannel = new Set(profilesInChannel[channel.id]);
acc.push(completeDirectChannelDisplayName(user.id, profiles, userIdsInChannel, settings || '', channel));
}
return acc;
}, [] as Channel[]);
return dms.concat(gms).sort(sortChannelsByDisplayName.bind(null, locale));
},
);

38
share_extension/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
interface BodyRef {
getValue(): string | undefined;
}
interface ShareFileInfo {
extension?: string;
filename: string;
fullPath: string;
mimeType: string;
size: string;
type: string;
}
interface ShareItem {
type: string;
value: string;
}
interface ShareState {
authorized?: boolean;
error?: string;
hasPermission?: boolean;
loading: boolean;
files: Array<ShareFileInfo>;
totalSize?: number;
value?: string;
}
interface ProcessedSharedItems {
error?: string;
files: Array<ShareFileInfo>;
value?: string;
totalSize?: number;
}

View file

@ -0,0 +1,230 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode} from 'react';
import {intlShape} from 'react-intl';
import {Alert, NativeModules, PermissionsAndroid} from 'react-native';
import LocalAuth from 'react-native-local-auth';
import RNFetchBlob from 'rn-fetch-blob';
import Loading from '@components/loading';
import {MAX_FILE_COUNT} from '@constants/post_draft';
import {getAppCredentials} from '@init/credentials';
import type {FileInfo} from '@mm-redux/types/files';
import type {Team} from '@mm-redux/types/teams';
import {getFormattedFileSize, lookupMimeType} from '@mm-redux/utils/file_utils';
import {getExtensionFromMime} from '@utils/file';
import mattermostManaged from 'app/mattermost_managed';
import ShareError from '@share/components/error';
const ShareExtension = NativeModules.MattermostShare;
export async function isAuthorized(intl: typeof intlShape): Promise<boolean> {
try {
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: intl.formatMessage({
id: 'mobile.managed.secured_by',
defaultMessage: 'Secured by {vendor}',
}, {vendor}),
fallbackToPasscode: true,
suppressEnterPassword: false,
});
} catch (err) {
ShareExtension.close(null);
}
} else {
await showNotSecuredAlert(intl, vendor);
ShareExtension.close(null);
}
}
}
} catch {
// do nothing
}
return getCredentials();
}
export async function getSharedItems(items: Array<ShareItem>, intl: typeof intlShape): Promise<ProcessedSharedItems> {
const text = [];
const files: Array<ShareFileInfo> = [];
let totalSize = 0;
let error;
for (let i = 0; i < items.length; i++) {
const item = items[i];
switch (item.type) {
case 'text/plain':
text.push(item.value);
break;
default: {
let fileSize = {size: 0};
const fullPath = item.value;
try {
fileSize = await RNFetchBlob.fs.stat(fullPath); // eslint-disable-line no-await-in-loop
} catch (e) {
error = intl.formatMessage({
id: 'mobile.extension.file_error',
defaultMessage: 'There was an error reading the file to be shared.\nPlease try again.',
});
break;
}
let filename = fullPath.replace(/^.*[\\/]/, '');
let extension = filename.split('.').pop();
if (extension === filename) {
extension = getExtensionFromMime(item.type);
filename = `${filename}.${extension}`;
}
totalSize += fileSize.size;
files.push({
extension,
filename,
fullPath,
mimeType: item.type || lookupMimeType(filename.toLowerCase()),
size: getFormattedFileSize(fileSize as FileInfo),
type: item.type,
});
break;
}
}
}
const value = text.join('\n');
return {error, files, value, totalSize};
}
export async function permissionEnabled(): Promise<boolean> {
const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE);
let granted;
if (!hasPermission) {
granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
);
}
return (hasPermission || granted === PermissionsAndroid.RESULTS.GRANTED);
}
export function getErrorElement(
state: ShareState,
canUploadFiles: boolean,
maxFileSize: number,
team: Team | undefined | null,
intl: typeof intlShape,
): ReactNode | undefined {
if (state.loading) {
return <Loading/>;
}
if (!state.authorized) {
return <ShareError/>;
}
if (!team?.id) {
const teamRequired = intl.formatMessage({
id: 'mobile.extension.team_required',
defaultMessage: 'You must belong to a team before you can share files.',
});
return <ShareError message={teamRequired}/>;
}
if (!canUploadFiles) {
const uploadsDisabled = intl.formatMessage({
id: 'mobile.file_upload.disabled',
defaultMessage: 'File uploads from mobile are disabled. Please contact your System Admin for more details.',
});
return <ShareError message={uploadsDisabled}/>;
}
if (state.hasPermission === false) {
const storage = intl.formatMessage({
id: 'mobile.extension.permission',
defaultMessage: 'Mattermost needs access to the device storage to share files.',
});
return <ShareError message={storage}/>;
}
if (state.files.length > MAX_FILE_COUNT) {
const fileCount = intl.formatMessage({
id: 'mobile.extension.file_limit',
defaultMessage: 'Sharing is limited to a maximum of 5 files.',
});
return <ShareError message={fileCount}/>;
}
if ((state.totalSize || 0) > maxFileSize) {
const maxSize = intl.formatMessage({
id: 'mobile.extension.max_file_size',
defaultMessage: 'File attachments shared in Mattermost must be less than {size}.',
}, {size: getFormattedFileSize({size: maxFileSize} as FileInfo)});
return <ShareError message={maxSize}/>;
}
return undefined;
}
async function getCredentials(): Promise<boolean> {
try {
const credentials = await getAppCredentials();
return Boolean(credentials);
} catch (error) {
return false;
}
return false;
}
function showNotSecuredAlert(intl: typeof intlShape, vendor: string): Promise<void> {
const {formatMessage} = intl;
return new Promise((resolve) => {
Alert.alert(
formatMessage({
id: 'mobile.managed.blocked_by',
defaultMessage: 'Blocked by {vendor}',
}, {vendor}),
formatMessage({
id: 'mobile.managed.not_secured.android',
defaultMessage: 'This device must be secured with a screen lock to use Mattermost.',
}),
[
{
text: formatMessage({
id: 'mobile.managed.settings',
defaultMessage: 'Go to settings',
}),
onPress: () => {
mattermostManaged.goToSecuritySettings();
},
},
{
text: formatMessage({
id: 'mobile.managed.exit',
defaultMessage: 'Exit',
}),
onPress: () => resolve(),
style: 'cancel',
},
],
{onDismiss: resolve},
);
});
}

View file

@ -42,7 +42,9 @@
"@mm-redux/*": ["app/mm-redux/*"],
"@screens/*": ["app/screens/*"],
"@selectors/*": ["app/selectors/*"],
"@store/*": ["app/store"],
"@share/*": ["share_extension/*"],
"@store": ["app/store/index"],
"@store/*": ["app/store/*"],
"@telemetry/*": ["/app/telemetry/*"],
"@utils/*": ["app/utils/*"],
"@websocket": ["app/client/websocket"],