From 043b3a0e8e582a59f2afae4073cb6761bd761ca4 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Mon, 19 Oct 2020 21:39:59 -0300 Subject: [PATCH] Refactor Android Share extension (js) (#4893) * Refactor Android Share extension (js) * Feedback review --- .../com/mattermost/share/ShareActivity.java | 5 - app/actions/views/channel.js | 4 +- app/constants/view.js | 1 - app/mm-redux/types/module.d.ts | 3 +- app/reducers/views/extension.js | 19 - app/reducers/views/index.js | 2 - app/store/index.ts | 4 +- app/utils/file.js | 6 +- babel.config.js | 1 + index.js | 2 +- package-lock.json | 54 ++ package.json | 2 + share_extension/actions/index.ts | 106 +++ share_extension/android/actions/index.js | 27 - .../extension_channel_item.js | 119 --- .../extension_channels/extension_channels.js | 284 ------- .../android/extension_channels/index.js | 22 - .../channel_button/channel_button.js | 82 -- .../extension_post/channel_button/index.js | 16 - .../android/extension_post/extension_post.js | 795 ------------------ .../extension_post/extension_post.test.js | 76 -- .../android/extension_post/index.js | 38 - .../extension_post/team_button/index.js | 16 - .../extension_post/team_button/team_button.js | 76 -- .../extension_teams/extension_teams.js | 122 --- .../android/extension_teams/index.js | 28 - .../extension_teams/team_item/index.js | 15 - .../extension_teams/team_item/team_item.js | 116 --- share_extension/android/index.js | 93 -- share_extension/common/selectors/index.js | 164 ---- share_extension/components/body.tsx | 102 +++ share_extension/components/channel_button.tsx | 89 ++ share_extension/components/channel_item.tsx | 100 +++ .../channel_type.tsx} | 34 +- .../components/chars_remaining.tsx | 51 ++ .../components/close_header_button.tsx | 47 ++ share_extension/components/error.tsx | 61 ++ share_extension/components/files.tsx | 91 ++ share_extension/components/image_file.tsx | 45 + share_extension/components/other_file.tsx | 68 ++ .../components/post_header_button.tsx | 51 ++ share_extension/components/team_button.tsx | 94 +++ share_extension/components/team_item.tsx | 104 +++ share_extension/components/video_file.tsx | 45 + share_extension/index.tsx | 55 ++ share_extension/screens/channel_list.tsx | 229 +++++ .../extension.js => screens/extension.tsx} | 36 +- .../navigation.js => screens/navigation.tsx} | 16 +- share_extension/screens/share.tsx | 230 +++++ share_extension/screens/team_list.tsx | 97 +++ share_extension/selectors/index.ts | 81 ++ share_extension/types/index.d.ts | 38 + share_extension/utils/index.tsx | 230 +++++ tsconfig.json | 4 +- 54 files changed, 2135 insertions(+), 2161 deletions(-) delete mode 100644 app/reducers/views/extension.js create mode 100644 share_extension/actions/index.ts delete mode 100644 share_extension/android/actions/index.js delete mode 100644 share_extension/android/extension_channels/extension_channel_item.js delete mode 100644 share_extension/android/extension_channels/extension_channels.js delete mode 100644 share_extension/android/extension_channels/index.js delete mode 100644 share_extension/android/extension_post/channel_button/channel_button.js delete mode 100644 share_extension/android/extension_post/channel_button/index.js delete mode 100644 share_extension/android/extension_post/extension_post.js delete mode 100644 share_extension/android/extension_post/extension_post.test.js delete mode 100644 share_extension/android/extension_post/index.js delete mode 100644 share_extension/android/extension_post/team_button/index.js delete mode 100644 share_extension/android/extension_post/team_button/team_button.js delete mode 100644 share_extension/android/extension_teams/extension_teams.js delete mode 100644 share_extension/android/extension_teams/index.js delete mode 100644 share_extension/android/extension_teams/team_item/index.js delete mode 100644 share_extension/android/extension_teams/team_item/team_item.js delete mode 100644 share_extension/android/index.js delete mode 100644 share_extension/common/selectors/index.js create mode 100644 share_extension/components/body.tsx create mode 100644 share_extension/components/channel_button.tsx create mode 100644 share_extension/components/channel_item.tsx rename share_extension/{common/icons/channel_type.js => components/channel_type.tsx} (70%) create mode 100644 share_extension/components/chars_remaining.tsx create mode 100644 share_extension/components/close_header_button.tsx create mode 100644 share_extension/components/error.tsx create mode 100644 share_extension/components/files.tsx create mode 100644 share_extension/components/image_file.tsx create mode 100644 share_extension/components/other_file.tsx create mode 100644 share_extension/components/post_header_button.tsx create mode 100644 share_extension/components/team_button.tsx create mode 100644 share_extension/components/team_item.tsx create mode 100644 share_extension/components/video_file.tsx create mode 100644 share_extension/index.tsx create mode 100644 share_extension/screens/channel_list.tsx rename share_extension/{android/extension.js => screens/extension.tsx} (58%) rename share_extension/{android/navigation.js => screens/navigation.tsx} (82%) create mode 100644 share_extension/screens/share.tsx create mode 100644 share_extension/screens/team_list.tsx create mode 100644 share_extension/selectors/index.ts create mode 100644 share_extension/types/index.d.ts create mode 100644 share_extension/utils/index.tsx diff --git a/android/app/src/main/java/com/mattermost/share/ShareActivity.java b/android/app/src/main/java/com/mattermost/share/ShareActivity.java index 640098538..573f9ac83 100644 --- a/android/app/src/main/java/com/mattermost/share/ShareActivity.java +++ b/android/app/src/main/java/com/mattermost/share/ShareActivity.java @@ -17,9 +17,4 @@ public class ShareActivity extends ReactActivity { MainApplication app = (MainApplication) this.getApplication(); app.sharedExtensionIsOpened = true; } - - @Override - public void onBackPressed() { - finish(); - } } diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index 49a3ff7d5..08a3a73be 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -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}; }; } diff --git a/app/constants/view.js b/app/constants/view.js index e37793ef9..40abe82da 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -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, diff --git a/app/mm-redux/types/module.d.ts b/app/mm-redux/types/module.d.ts index e37f27d29..fa69b23b2 100644 --- a/app/mm-redux/types/module.d.ts +++ b/app/mm-redux/types/module.d.ts @@ -3,4 +3,5 @@ declare module 'gfycat-sdk'; declare module 'remote-redux-devtools'; declare module 'redux-action-buffer'; -declare module 'react-intl'; \ No newline at end of file +declare module 'react-intl'; +declare module 'react-native-local-auth'; \ No newline at end of file diff --git a/app/reducers/views/extension.js b/app/reducers/views/extension.js deleted file mode 100644 index 826ffb5cc..000000000 --- a/app/reducers/views/extension.js +++ /dev/null @@ -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, -}); diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js index 4b15ee1d3..820c597b6 100644 --- a/app/reducers/views/index.js +++ b/app/reducers/views/index.js @@ -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, diff --git a/app/store/index.ts b/app/store/index.ts index afc4c25d0..206f3facc 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -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, diff --git a/app/utils/file.js b/app/utils/file.js index 083770c93..d07cb14ae 100644 --- a/app/utils/file.js +++ b/app/utils/file.js @@ -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]; diff --git a/babel.config.js b/babel.config.js index 1a0d77bb2..b9e606061 100644 --- a/babel.config.js +++ b/babel.config.js @@ -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', diff --git a/index.js b/index.js index d0baa503b..36ab2d9a0 100644 --- a/index.js +++ b/index.js @@ -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(); diff --git a/package-lock.json b/package-lock.json index 5996dd705..479ebfc64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 376c81f06..dd5c3d08d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/share_extension/actions/index.ts b/share_extension/actions/index.ts new file mode 100644 index 000000000..5d9002c16 --- /dev/null +++ b/share_extension/actions/index.ts @@ -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(); + 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; + }; +} diff --git a/share_extension/android/actions/index.js b/share_extension/android/actions/index.js deleted file mode 100644 index 240e54ac3..000000000 --- a/share_extension/android/actions/index.js +++ /dev/null @@ -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, - }; -} diff --git a/share_extension/android/extension_channels/extension_channel_item.js b/share_extension/android/extension_channels/extension_channel_item.js deleted file mode 100644 index 523ae0735..000000000 --- a/share_extension/android/extension_channels/extension_channel_item.js +++ /dev/null @@ -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 = ( - - - - ); - } - - const Type = channelTypes[channel.type] || PublicChannel; - const icon = ; - - return ( - - - - {icon} - - {channel.display_name} - - {current} - - - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - container: { - flex: 1, - flexDirection: 'row', - height: 45, - paddingHorizontal: 15, - }, - item: { - alignItems: 'center', - height: 45, - flex: 1, - flexDirection: 'row', - }, - text: { - color: theme.centerChannelColor, - flex: 1, - fontSize: 16, - paddingRight: 5, - }, - iconContainer: { - marginRight: 5, - }, - checkmarkContainer: { - alignItems: 'flex-end', - }, - checkmark: { - color: theme.linkColor, - fontSize: 16, - }, - }; -}); diff --git a/share_extension/android/extension_channels/extension_channels.js b/share_extension/android/extension_channels/extension_channels.js deleted file mode 100644 index df69a3d45..000000000 --- a/share_extension/android/extension_channels/extension_channels.js +++ /dev/null @@ -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 ( - - - - ); - } - - return ( - - {this.renderSearchBar(styles)} - - - ); - }; - - renderItem = ({item}) => { - const {route} = this.props; - const {currentChannelId} = route.params; - - return ( - - ); - }; - - renderItemSeparator = () => { - const styles = getStyleSheet(defaultTheme); - - return ( - - ); - }; - - renderSearchBar = (styles) => { - const {formatMessage} = this.context.intl; - - return ( - - - - ); - }; - - renderSectionHeader = ({section}) => { - const {intl} = this.context; - const styles = getStyleSheet(defaultTheme); - const { - defaultMessage, - id, - } = section; - - return ( - - - - {intl.formatMessage({id, defaultMessage}).toUpperCase()} - - - - ); - }; - - render() { - const styles = getStyleSheet(defaultTheme); - - return ( - - {this.renderBody(styles)} - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - flex: { - flex: 1, - }, - separator: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), - height: 1, - }, - loadingContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - searchContainer: { - paddingBottom: 2, - 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, - }, - }; -}); diff --git a/share_extension/android/extension_channels/index.js b/share_extension/android/extension_channels/index.js deleted file mode 100644 index 2c3791c17..000000000 --- a/share_extension/android/extension_channels/index.js +++ /dev/null @@ -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); diff --git a/share_extension/android/extension_post/channel_button/channel_button.js b/share_extension/android/extension_post/channel_button/channel_button.js deleted file mode 100644 index 9e7230942..000000000 --- a/share_extension/android/extension_post/channel_button/channel_button.js +++ /dev/null @@ -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 ( - - - - {formatMessage({id: 'mobile.share_extension.channel', defaultMessage: 'Channel'})} - - - {channelName} - - - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - flex: { - flex: 1, - }, - buttonContainer: { - borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2), - borderBottomWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), - borderTopWidth: 1, - height: 70, - paddingHorizontal: 15, - }, - buttonWrapper: { - alignItems: 'flex-start', - flex: 1, - }, - buttonLabel: { - fontSize: 16, - marginTop: 16, - marginBottom: 3, - }, - buttonValue: { - color: changeOpacity(theme.centerChannelColor, 0.6), - fontSize: 14, - }, - }; -}); diff --git a/share_extension/android/extension_post/channel_button/index.js b/share_extension/android/extension_post/channel_button/index.js deleted file mode 100644 index 8b844f739..000000000 --- a/share_extension/android/extension_post/channel_button/index.js +++ /dev/null @@ -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); diff --git a/share_extension/android/extension_post/extension_post.js b/share_extension/android/extension_post/extension_post.js deleted file mode 100644 index b674a5056..000000000 --- a/share_extension/android/extension_post/extension_post.js +++ /dev/null @@ -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 = () => ( - - - - - - ); - - 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 ( - - - {this.renderFiles()} - - ); - }; - - renderChannelButton = () => { - const {channelId} = this.state; - - return ( - - ); - }; - - renderErrorMessage = (message) => { - return ( - - - - {message} - - - - ); - }; - - renderFiles = () => { - const {files} = this.state; - - return files.map((file, index) => { - let component; - - if (file.type.startsWith('image')) { - component = ( - - - - ); - } else if (file.type.startsWith('video')) { - component = ( - - - ); - } else { - const iconName = iconForExtension[file.extension] || iconForExtension.generic; - - component = ( - - - - - - - - ); - } - - return ( - - {component} - - {`${file.size} - ${file.filename}`} - - - ); - }); - }; - - rightHeader = () => ( - - - - - - ); - - renderTeamButton = () => { - const {teamId} = this.state; - - return ( - - ); - }; - - 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 ( - - {messageLengthRemaining} - - ); - }; - - render() { - const {formatMessage} = this.context.intl; - const {maxFileSize} = this.props; - const {error, hasPermission, files, totalSize, loaded, teamId} = this.state; - - if (!loaded) { - return ( - - ); - } - - 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 ( - - - {this.renderBody()} - - {this.renderMessageLengthRemaining()} - {this.renderTeamButton()} - {this.renderChannelButton()} - - - - ); - } - - 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); diff --git a/share_extension/android/extension_post/extension_post.test.js b/share_extension/android/extension_post/extension_post.test.js deleted file mode 100644 index 9a97e5505..000000000 --- a/share_extension/android/extension_post/extension_post.test.js +++ /dev/null @@ -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( - , - ); - - 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.'); - }); -}); diff --git a/share_extension/android/extension_post/index.js b/share_extension/android/extension_post/index.js deleted file mode 100644 index 2a0347497..000000000 --- a/share_extension/android/extension_post/index.js +++ /dev/null @@ -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); diff --git a/share_extension/android/extension_post/team_button/index.js b/share_extension/android/extension_post/team_button/index.js deleted file mode 100644 index 73c4c66bc..000000000 --- a/share_extension/android/extension_post/team_button/index.js +++ /dev/null @@ -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); diff --git a/share_extension/android/extension_post/team_button/team_button.js b/share_extension/android/extension_post/team_button/team_button.js deleted file mode 100644 index 38ae11c54..000000000 --- a/share_extension/android/extension_post/team_button/team_button.js +++ /dev/null @@ -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 ( - - - - {formatMessage({id: 'mobile.share_extension.team', defaultMessage: 'Team'})} - - - {teamName} - - - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - flex: { - flex: 1, - }, - buttonContainer: { - borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), - borderTopWidth: 1, - height: 70, - paddingHorizontal: 15, - }, - buttonWrapper: { - alignItems: 'flex-start', - flex: 1, - }, - buttonLabel: { - fontSize: 16, - marginTop: 16, - marginBottom: 3, - }, - buttonValue: { - color: changeOpacity(theme.centerChannelColor, 0.6), - fontSize: 14, - }, - }; -}); diff --git a/share_extension/android/extension_teams/extension_teams.js b/share_extension/android/extension_teams/extension_teams.js deleted file mode 100644 index 57728c2b3..000000000 --- a/share_extension/android/extension_teams/extension_teams.js +++ /dev/null @@ -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 ( - - ); - }; - - renderItem = ({item}) => { - const {params} = this.props.route; - - return ( - - ); - }; - - render() { - const styles = getStyleSheet(defaultTheme); - - if (this.state.loading) { - return ( - - - - ); - } - - return ( - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - loadingContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - separator: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), - height: 1, - }, - }; -}); diff --git a/share_extension/android/extension_teams/index.js b/share_extension/android/extension_teams/index.js deleted file mode 100644 index 1a6ce1bab..000000000 --- a/share_extension/android/extension_teams/index.js +++ /dev/null @@ -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); diff --git a/share_extension/android/extension_teams/team_item/index.js b/share_extension/android/extension_teams/team_item/index.js deleted file mode 100644 index aa4cda5a1..000000000 --- a/share_extension/android/extension_teams/team_item/index.js +++ /dev/null @@ -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); diff --git a/share_extension/android/extension_teams/team_item/team_item.js b/share_extension/android/extension_teams/team_item/team_item.js deleted file mode 100644 index 234b3abc9..000000000 --- a/share_extension/android/extension_teams/team_item/team_item.js +++ /dev/null @@ -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 = ( - - - - ); - } - - return ( - - - - - - {team.display_name} - - {current} - - - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - container: { - flex: 1, - flexDirection: 'row', - height: 45, - paddingHorizontal: 15, - }, - item: { - alignItems: 'center', - height: 45, - flex: 1, - flexDirection: 'row', - }, - text: { - color: theme.centerChannelColor, - flex: 1, - fontSize: 16, - paddingRight: 5, - }, - teamIconContainer: { - backgroundColor: theme.sidebarBg, - marginRight: 10, - }, - teamIconText: { - color: theme.sidebarText, - }, - imageContainer: { - backgroundColor: '#ffffff', - }, - checkmarkContainer: { - alignItems: 'flex-end', - }, - checkmark: { - color: theme.linkColor, - fontSize: 16, - }, - }; -}); diff --git a/share_extension/android/index.js b/share_extension/android/index.js deleted file mode 100644 index c55234256..000000000 --- a/share_extension/android/index.js +++ /dev/null @@ -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 ( - - - - - - ); - } -} diff --git a/share_extension/common/selectors/index.js b/share_extension/common/selectors/index.js deleted file mode 100644 index f9c48c34f..000000000 --- a/share_extension/common/selectors/index.js +++ /dev/null @@ -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; -} diff --git a/share_extension/components/body.tsx b/share_extension/components/body.tsx new file mode 100644 index 000000000..cd9d4d3e2 --- /dev/null +++ b/share_extension/components/body.tsx @@ -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, calculatedSize?: number) => void; + files: Array; + initialValue?: string; + placeholder: string; +} + +const INPUT_HEIGHT = 150; +const theme = Preferences.THEMES.default; + +const Body = forwardRef(({canPost, files, initialValue, placeholder}: BodyProps, ref) => { + const scrollViewRef = useRef(null); + const inputRef = useRef(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 ( + <> + + + + + + + ); +}); + +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; diff --git a/share_extension/components/channel_button.tsx b/share_extension/components/channel_button.tsx new file mode 100644 index 000000000..f2ccc7830 --- /dev/null +++ b/share_extension/components/channel_button.tsx @@ -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 ( + + + + + {channel?.display_name} + + + + ); +}; + +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); diff --git a/share_extension/components/channel_item.tsx b/share_extension/components/channel_item.tsx new file mode 100644 index 000000000..4fa9aebbf --- /dev/null +++ b/share_extension/components/channel_item.tsx @@ -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 = { + D: DirectChannel, + G: GroupChannel, + O: PublicChannel, + P: PrivateChannel, +}; + +const ChannelItem = ({onSelect, selected, channel}: ChannelItemProps) => { + const onPress = preventDoubleTap(() => { + onSelect(channel); + }); + + let current; + if (selected) { + current = ( + + + + ); + } + + return ( + + + + {channelTypes[channel.type] || PublicChannel} + + {channel.display_name} + + {current} + + + + ); +}; + +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; diff --git a/share_extension/common/icons/channel_type.js b/share_extension/components/channel_type.tsx similarity index 70% rename from share_extension/common/icons/channel_type.js rename to share_extension/components/channel_type.tsx index f9f13c485..81326d7ff 100644 --- a/share_extension/common/icons/channel_type.js +++ b/share_extension/components/channel_type.tsx @@ -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); diff --git a/share_extension/components/chars_remaining.tsx b/share_extension/components/chars_remaining.tsx new file mode 100644 index 000000000..1ea233ba7 --- /dev/null +++ b/share_extension/components/chars_remaining.tsx @@ -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 ( + + + {messageLengthRemaining} + + + ); +}; + +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; diff --git a/share_extension/components/close_header_button.tsx b/share_extension/components/close_header_button.tsx new file mode 100644 index 000000000..66bb544cf --- /dev/null +++ b/share_extension/components/close_header_button.tsx @@ -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 ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + left: { + alignItems: 'center', + height: 50, + justifyContent: 'center', + width: 50, + }, + closeButton: { + color: theme.sidebarHeaderTextColor, + fontSize: 25, + }, +}); + +export default CloseHeaderButton; diff --git a/share_extension/components/error.tsx b/share_extension/components/error.tsx new file mode 100644 index 000000000..229bcb2e5 --- /dev/null +++ b/share_extension/components/error.tsx @@ -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 = ( + + {message} + + ); + } else { + error = ( + + ); + } + + return ( + + + {error} + + + ); +}; + +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; diff --git a/share_extension/components/files.tsx b/share_extension/components/files.tsx new file mode 100644 index 000000000..95925247a --- /dev/null +++ b/share_extension/components/files.tsx @@ -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; +} + +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 = ( + + ); + } else if (file.type.startsWith('video')) { + component = ( + + ); + } else { + component = ( + + ); + } + + return ( + + {component} + + {`${file.size} - ${file.filename}`} + + + ); + }); + + 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; diff --git a/share_extension/components/image_file.tsx b/share_extension/components/image_file.tsx new file mode 100644 index 000000000..a97942a2c --- /dev/null +++ b/share_extension/components/image_file.tsx @@ -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 ( + + + + ); +}; + +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; diff --git a/share_extension/components/other_file.tsx b/share_extension/components/other_file.tsx new file mode 100644 index 000000000..6045989e6 --- /dev/null +++ b/share_extension/components/other_file.tsx @@ -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 = { + 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 ( + + + + + + + + ); +}; + +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; diff --git a/share_extension/components/post_header_button.tsx b/share_extension/components/post_header_button.tsx new file mode 100644 index 000000000..b35b4195b --- /dev/null +++ b/share_extension/components/post_header_button.tsx @@ -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 ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + left: { + alignItems: 'center', + height: 50, + justifyContent: 'center', + width: 50, + }, +}); + +export default PostHeaderButton; diff --git a/share_extension/components/team_button.tsx b/share_extension/components/team_button.tsx new file mode 100644 index 000000000..2fd357787 --- /dev/null +++ b/share_extension/components/team_button.tsx @@ -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 ( + + + + + {team?.display_name} + + + + ); +}; + +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); diff --git a/share_extension/components/team_item.tsx b/share_extension/components/team_item.tsx new file mode 100644 index 000000000..8ebe77c78 --- /dev/null +++ b/share_extension/components/team_item.tsx @@ -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 = ( + + + + ); + } + + return ( + + + + + + {team.display_name} + + {current} + + + + ); +}; + +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; diff --git a/share_extension/components/video_file.tsx b/share_extension/components/video_file.tsx new file mode 100644 index 000000000..96272b28b --- /dev/null +++ b/share_extension/components/video_file.tsx @@ -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 ( + + + ); +}; + +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; diff --git a/share_extension/index.tsx b/share_extension/index.tsx new file mode 100644 index 000000000..adb876740 --- /dev/null +++ b/share_extension/index.tsx @@ -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 ( + + + + + + ); +}; + +export default ShareExtension; diff --git a/share_extension/screens/channel_list.tsx b/share_extension/screens/channel_list.tsx new file mode 100644 index 000000000..2034315cf --- /dev/null +++ b/share_extension/screens/channel_list.tsx @@ -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}) => React.ReactElement | null; + +export type ChannelListRoute = RouteProp; + +const theme = Preferences.THEMES.default; + +const ChannelList = ({intl}: ChannnelListProps) => { + const navigation = useNavigation(); + const route = useRoute(); + 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(); + const [term, setTerm] = useState(); + + const handleSearch = useCallback((text) => { + throttle(setTerm(text)); + }, []); + + const keyExtractor = (item: Channel) => item?.id; + const renderItemSeparator = () => (); + const renderSectionHeader: SectionDataHeader = ({section}) => ( + + + + + + ); + const renderItem = ({item}: SectionListRenderItemInfo) => ( + + ); + + 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 ; + } + + return ( + + + + + + + ); +}; + +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); diff --git a/share_extension/android/extension.js b/share_extension/screens/extension.tsx similarity index 58% rename from share_extension/android/extension.js rename to share_extension/screens/extension.tsx index 08ddb8acf..3ffd60b28 100644 --- a/share_extension/android/extension.js +++ b/share_extension/screens/extension.tsx @@ -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 ( + + + + ); + } + return ; } } diff --git a/share_extension/android/navigation.js b/share_extension/screens/navigation.tsx similarity index 82% rename from share_extension/android/navigation.js rename to share_extension/screens/navigation.tsx index 5e3be62d6..def38058a 100644 --- a/share_extension/android/navigation.js +++ b/share_extension/screens/navigation.tsx @@ -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() { > ); diff --git a/share_extension/screens/share.tsx b/share_extension/screens/share.tsx new file mode 100644 index 000000000..a9d17e1ae --- /dev/null +++ b/share_extension/screens/share.tsx @@ -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 = () => ( + ShareExtension.close(null)} + /> +); + +const rightButton = () => (); + +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(null); + const [state, setState] = useState(initialState); + const [team, setTeam] = useState(currentTeam); + const [channel, setChannel] = useState(currentChannel); + const maxFileSize = getAllowedServerMaxFileSize(config); + + const showPostButton = (error?: string, text?: string, extensionFiles?: Array, 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 = 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 ( + + + + + + + + + + ); +}; + +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); \ No newline at end of file diff --git a/share_extension/screens/team_list.tsx b/share_extension/screens/team_list.tsx new file mode 100644 index 000000000..7a85e6177 --- /dev/null +++ b/share_extension/screens/team_list.tsx @@ -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; + +type ListItem = (info: {item: Team}) => React.ReactElement; + +const theme = Preferences.THEMES.default; + +const TeamList = () => { + const navigation = useNavigation(); + const route = useRoute(); + 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 = () => (); + const renderItem: ListItem = ({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 ; + } + + return ( + + ); +}; + +const styles = StyleSheet.create({ + separator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + }, +}); + +export default TeamList; diff --git a/share_extension/selectors/index.ts b/share_extension/selectors/index.ts new file mode 100644 index 000000000..4e6128e12 --- /dev/null +++ b/share_extension/selectors/index.ts @@ -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)); + }, +); diff --git a/share_extension/types/index.d.ts b/share_extension/types/index.d.ts new file mode 100644 index 000000000..a8ebf1a59 --- /dev/null +++ b/share_extension/types/index.d.ts @@ -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; + totalSize?: number; + value?: string; +} + +interface ProcessedSharedItems { + error?: string; + files: Array; + value?: string; + totalSize?: number; +} + diff --git a/share_extension/utils/index.tsx b/share_extension/utils/index.tsx new file mode 100644 index 000000000..d9e645a03 --- /dev/null +++ b/share_extension/utils/index.tsx @@ -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 { + 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, intl: typeof intlShape): Promise { + const text = []; + const files: Array = []; + 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 { + 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 ; + } + + if (!state.authorized) { + return ; + } + + 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 ; + } + + 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 ; + } + + if (state.hasPermission === false) { + const storage = intl.formatMessage({ + id: 'mobile.extension.permission', + defaultMessage: 'Mattermost needs access to the device storage to share files.', + }); + + return ; + } + + 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 ; + } + + 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 ; + } + + return undefined; +} + +async function getCredentials(): Promise { + try { + const credentials = await getAppCredentials(); + return Boolean(credentials); + } catch (error) { + return false; + } + + return false; +} + +function showNotSecuredAlert(intl: typeof intlShape, vendor: string): Promise { + 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}, + ); + }); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index dcdab2ea2..47a456f1e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"],