From 7bb777f4b3b79ab19f1f65319cb7c16b5ce1ffed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 4 Jan 2021 23:06:37 +0100 Subject: [PATCH] Handle go to location from CommandResponse (#4620) * First draft to handle go to location on mobile * Fix lint * Fix test * Remove unnecessary change * Add not handled cases * Add i18n missing string * Fix typo * Extract handleGotoLocation into an action * Fix minor issues and extract showPermalinkView to an action * Fix minor issues and extract showPermalinkView to an action * Add missing change * Fix this reference * Remove unneeded event handlers, sort imports, early handle errors, make group channel visible, remove duplications and move functions to the right place * Fix tests * Handle error when opening permalink --- app/actions/views/channel.js | 4 +- app/actions/views/permalink.ts | 1 + .../markdown/markdown_link/markdown_link.js | 16 +---- .../post_draft/draft_input/draft_input.js | 7 ++- .../draft_input/draft_input.test.js | 1 + .../post_draft/draft_input/index.js | 2 + app/components/post_list/post_list.js | 16 +---- app/components/post_list/post_list.test.js | 2 + app/constants/deep_linking.js | 2 + app/mm-redux/actions/integrations.ts | 60 +++++++++++++++++++ app/utils/draft.js | 42 ++++++++++++- app/utils/url.js | 28 +++++++++ assets/base/i18n/en.json | 1 + 13 files changed, 153 insertions(+), 29 deletions(-) diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index b8da9b1a1..e795a009c 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -213,6 +213,8 @@ export function handleSelectChannel(channelId) { console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line } + + return {data: true}; }; } @@ -258,7 +260,7 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler) { dispatch(handleSelectChannel(channel.id)); } - return null; + return {data: true}; }; } diff --git a/app/actions/views/permalink.ts b/app/actions/views/permalink.ts index a92356333..e169cb7c9 100644 --- a/app/actions/views/permalink.ts +++ b/app/actions/views/permalink.ts @@ -40,6 +40,7 @@ export function showPermalink(intl: typeof intlShape, teamName: string, postId: showModalOverCurrentContext(screen, passProps, options); } } + return {}; }; } diff --git a/app/components/markdown/markdown_link/markdown_link.js b/app/components/markdown/markdown_link/markdown_link.js index 1c1f37046..f34557087 100644 --- a/app/components/markdown/markdown_link/markdown_link.js +++ b/app/components/markdown/markdown_link/markdown_link.js @@ -13,8 +13,7 @@ import {DeepLinkTypes} from '@constants'; import CustomPropTypes from '@constants/custom_prop_types'; import {getCurrentServerUrl} from '@init/credentials'; import BottomSheet from '@utils/bottom_sheet'; -import {alertErrorWithFallback} from '@utils/general'; -import {t} from '@utils/i18n'; +import {errorBadChannel} from '@utils/draft'; import {preventDoubleTap} from '@utils/tap'; import {matchDeepLink, normalizeProtocol} from '@utils/url'; @@ -59,7 +58,8 @@ export default class MarkdownLink extends PureComponent { if (match) { if (match.type === DeepLinkTypes.CHANNEL) { - this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.errorBadChannel); + const {intl} = this.context; + this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel(intl)); } else if (match.type === DeepLinkTypes.PERMALINK) { onPermalinkPress(match.postId, match.teamName); } @@ -84,16 +84,6 @@ export default class MarkdownLink extends PureComponent { } }); - errorBadChannel = () => { - const {intl} = this.context; - const message = { - id: t('mobile.server_link.unreachable_channel.error'), - defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.', - }; - - alertErrorWithFallback(intl, {}, message); - }; - parseLinkLiteral = (literal) => { let nextLiteral = literal; diff --git a/app/components/post_draft/draft_input/draft_input.js b/app/components/post_draft/draft_input/draft_input.js index a474687e9..aa100e31f 100644 --- a/app/components/post_draft/draft_input/draft_input.js +++ b/app/components/post_draft/draft_input/draft_input.js @@ -44,6 +44,7 @@ export default class DraftInput extends PureComponent { getChannelTimezones: PropTypes.func.isRequired, handleClearFiles: PropTypes.func.isRequired, handleClearFailedFiles: PropTypes.func.isRequired, + handleGotoLocation: PropTypes.func.isRequired, isLandscape: PropTypes.bool.isRequired, isTimezoneEnabled: PropTypes.bool, maxMessageLength: PropTypes.number.isRequired, @@ -299,7 +300,7 @@ export default class DraftInput extends PureComponent { return; } - const {error} = await executeCommand(msg, channelId, rootId); + const {data, error} = await executeCommand(msg, channelId, rootId); this.setState({sendingMessage: false}); if (error) { @@ -310,6 +311,10 @@ export default class DraftInput extends PureComponent { this.setInputValue(''); this.input.current.changeDraft(''); + + if (data.goto_location) { + this.props.handleGotoLocation(data.goto_location, this.context.intl); + } }; sendMessage = (value = '') => { diff --git a/app/components/post_draft/draft_input/draft_input.test.js b/app/components/post_draft/draft_input/draft_input.test.js index 298eafb0a..7b962bc77 100644 --- a/app/components/post_draft/draft_input/draft_input.test.js +++ b/app/components/post_draft/draft_input/draft_input.test.js @@ -80,6 +80,7 @@ describe('DraftInput', () => { }, membersCount: 10, addRecentUsedEmojisInMessage: jest.fn(), + handleGotoLocation: jest.fn(), }; const ref = React.createRef(); diff --git a/app/components/post_draft/draft_input/index.js b/app/components/post_draft/draft_input/index.js index 6fc35bfd9..ac63bc47b 100644 --- a/app/components/post_draft/draft_input/index.js +++ b/app/components/post_draft/draft_input/index.js @@ -8,6 +8,7 @@ import {addReactionToLatestPost, addRecentUsedEmojisInMessage} from '@actions/vi import {handleClearFiles, handleClearFailedFiles} from '@actions/views/file_upload'; import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft'; import {getChannelTimezones, getChannelMemberCountsByGroup} from '@mm-redux/actions/channels'; +import {handleGotoLocation} from '@mm-redux/actions/integrations'; import {createPost} from '@mm-redux/actions/posts'; import {setStatus} from '@mm-redux/actions/users'; import {General, Permissions} from '@mm-redux/constants'; @@ -99,6 +100,7 @@ const mapDispatchToProps = { getChannelTimezones, handleClearFiles, handleClearFailedFiles, + handleGotoLocation, setStatus, getChannelMemberCountsByGroup, addRecentUsedEmojisInMessage, diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index 5d607e411..85c800d13 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -9,6 +9,7 @@ import {intlShape} from 'react-intl'; import {Posts} from '@mm-redux/constants'; import EventEmitter from '@mm-redux/utils/event_emitter'; import * as PostListUtils from '@mm-redux/utils/post_list'; +import {errorBadChannel} from '@utils/draft'; import CombinedUserActivityPost from 'app/components/combined_user_activity_post'; import Post from 'app/components/post'; @@ -17,8 +18,6 @@ import mattermostManaged from 'app/mattermost_managed'; import {makeExtraData} from 'app/utils/list_view'; import {matchDeepLink} from 'app/utils/url'; import telemetry from 'app/telemetry'; -import {alertErrorWithFallback} from 'app/utils/general'; -import {t} from 'app/utils/i18n'; import DateHeader from './date_header'; import NewMessagesDivider from './new_messages_divider'; @@ -188,7 +187,8 @@ export default class PostList extends PureComponent { if (match) { if (match.type === DeepLinkTypes.CHANNEL) { - this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.permalinkBadChannel); + const {intl} = this.context; + this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel(intl)); } else if (match.type === DeepLinkTypes.PERMALINK) { this.handlePermalinkPress(match.postId, match.teamName); } @@ -277,16 +277,6 @@ export default class PostList extends PureComponent { }); }; - permalinkBadChannel = () => { - const {intl} = this.context; - const message = { - id: t('mobile.server_link.unreachable_channel.error'), - defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.', - }; - - alertErrorWithFallback(intl, {}, message); - }; - renderItem = ({item, index}) => { const { testID, diff --git a/app/components/post_list/post_list.test.js b/app/components/post_list/post_list.test.js index 5aec29c99..110223a5e 100644 --- a/app/components/post_list/post_list.test.js +++ b/app/components/post_list/post_list.test.js @@ -14,6 +14,7 @@ jest.useFakeTimers(); jest.mock('react-intl'); describe('PostList', () => { + const formatMessage = jest.fn(); const serverURL = 'https://server-url.fake'; const deeplinkRoot = 'mattermost://server-url.fake'; @@ -61,6 +62,7 @@ describe('PostList', () => { test('setting channel deep link', () => { const wrapper = shallow( , + {context: {intl: {formatMessage}}}, ); wrapper.setProps({deepLinkURL: deepLinks.channel}); diff --git a/app/constants/deep_linking.js b/app/constants/deep_linking.js index f3ac632db..31988cb4d 100644 --- a/app/constants/deep_linking.js +++ b/app/constants/deep_linking.js @@ -3,6 +3,8 @@ export default { CHANNEL: 'channel', + DMCHANNEL: 'dmchannel', + GROUPCHANNEL: 'groupchannel', PERMALINK: 'permalink', OTHER: 'other', }; diff --git a/app/mm-redux/actions/integrations.ts b/app/mm-redux/actions/integrations.ts index bb7ece7d1..7b6122714 100644 --- a/app/mm-redux/actions/integrations.ts +++ b/app/mm-redux/actions/integrations.ts @@ -14,6 +14,17 @@ import {Command, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingWebhook} f import {logError} from './errors'; import {bindClientFunc, forceLogoutIfNecessary, requestSuccess, requestFailure} from './helpers'; +import {makeGroupMessageVisibleIfNecessary} from './preferences'; +import {handleSelectChannel, handleSelectChannelByName, loadChannelsByTeamName} from '@actions/views/channel'; +import {makeDirectChannel} from '@actions/views/more_dms'; +import {showPermalink} from '@actions/views/permalink'; +import {DeepLinkTypes} from '@constants'; +import {getUserByUsername} from '@mm-redux/actions/users'; +import {getConfig, getCurrentUrl} from '@mm-redux/selectors/entities/general'; +import * as DraftUtils from '@utils/draft'; +import {permalinkBadTeam} from '@utils/general'; +import {getURLAndMatch} from '@utils/url'; + export function createIncomingHook(hook: IncomingWebhook): ActionFunc { return bindClientFunc({ clientFunc: Client4.createIncomingWebhook, @@ -399,3 +410,52 @@ export function submitInteractiveDialog(submission: DialogSubmission): ActionFun return {data}; }; } + +export function handleGotoLocation(href: string, intl: any): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const config = getConfig(state); + + const {url, match} = await getURLAndMatch(href, getCurrentUrl(state), config.SiteURL); + + if (match) { + switch (match.type) { + case DeepLinkTypes.CHANNEL: + dispatch(handleSelectChannelByName(match.channelName, match.teamName, () => DraftUtils.errorBadChannel(intl))); + break; + case DeepLinkTypes.PERMALINK: { + const {error} = await dispatch(loadChannelsByTeamName(match.teamName, () => permalinkBadTeam(intl))); + if (!error && match.postId) { + dispatch(showPermalink(intl, match.teamName, match.postId)); + } + break; + } + case DeepLinkTypes.DMCHANNEL: { + if (!match.userName) { + DraftUtils.errorBadUser(intl); + return {data: false}; + } + + const {data} = await dispatch(getUserByUsername(match.userName)); + if (!data) { + DraftUtils.errorBadUser(intl); + return {data: false}; + } + dispatch(makeDirectChannel(data.id)); + break; + } + case DeepLinkTypes.GROUPCHANNEL: + if (!match.id) { + DraftUtils.errorBadChannel(intl); + return {data: false}; + } + dispatch(makeGroupMessageVisibleIfNecessary(match.id)); + dispatch(handleSelectChannel(match.id)); + break; + } + } else { + DraftUtils.tryOpenURL(url); + } + return {data: true}; + }; +} diff --git a/app/utils/draft.js b/app/utils/draft.js index 1d26f9025..9d4053904 100644 --- a/app/utils/draft.js +++ b/app/utils/draft.js @@ -1,11 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Alert} from 'react-native'; +import {Alert, Linking} from 'react-native'; import {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from '@constants/autocomplete'; import {NOTIFY_ALL_MEMBERS} from '@constants/view'; import {General} from '@mm-redux/constants'; +import {alertErrorWithFallback} from '@utils/general'; +import {t} from '@utils/i18n'; export function alertAttachmentFail(formatMessage, accept, cancel) { Alert.alert( @@ -79,6 +81,44 @@ export function alertSendToGroups(formatMessage, notifyAllMessage, accept, cance ); } +export function tryOpenURL(url) { + Linking.canOpenURL(url).then((supported) => { + if (supported) { + Linking.openURL(url); + } else { + const {formatMessage} = this.context.intl; + Alert.alert( + formatMessage({ + id: 'mobile.server_link.error.title', + defaultMessage: 'Link Error', + }), + formatMessage({ + id: 'mobile.server_link.error.text', + defaultMessage: 'The link could not be found on this server.', + }), + ); + } + }); +} + +export function errorBadChannel(intl) { + const message = { + id: t('mobile.server_link.unreachable_channel.error'), + defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.', + }; + + alertErrorWithFallback(intl, {}, message); +} + +export function errorBadUser(intl) { + const message = { + id: t('mobile.server_link.unreachable_user.error'), + defaultMessage: 'This link belongs to a deleted user.', + }; + + alertErrorWithFallback(intl, {}, message); +} + export function alertSlashCommandFailed(formatMessage, error) { Alert.alert( formatMessage({ diff --git a/app/utils/url.js b/app/utils/url.js index ffe5a4e16..d002ad9f5 100644 --- a/app/utils/url.js +++ b/app/utils/url.js @@ -5,6 +5,7 @@ import {latinise} from './latinise.js'; import {escapeRegex} from './markdown'; import {Files} from '@mm-redux/constants'; +import {getCurrentServerUrl} from '@init/credentials'; import {DeepLinkTypes} from 'app/constants'; @@ -128,6 +129,16 @@ export function matchDeepLink(url, serverURL, siteURL) { return {type: DeepLinkTypes.PERMALINK, teamName: match[1], postId: match[2]}; } + match = new RegExp(linkRoot + '\\/([^\\/]+)\\/messages\\/@(\\S+)').exec(urlToMatch); + if (match) { + return {type: DeepLinkTypes.DMCHANNEL, teamName: match[1], userName: match[2]}; + } + + match = new RegExp(linkRoot + '\\/([^\\/]+)\\/messages\\/(\\S+)').exec(urlToMatch); + if (match) { + return {type: DeepLinkTypes.GROUPCHANNEL, teamName: match[1], id: match[2]}; + } + return null; } @@ -152,3 +163,20 @@ export function getYouTubeVideoId(link) { return ''; } + +export async function getURLAndMatch(href, serverURL, siteURL) { + const url = normalizeProtocol(href); + + if (!url) { + return {}; + } + + let serverUrl = serverURL; + if (!serverUrl) { + serverUrl = await getCurrentServerUrl(); + } + + const match = matchDeepLink(url, serverURL, siteURL); + + return {url, match}; +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 48fd7168c..35c4de889 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -467,6 +467,7 @@ "mobile.server_link.error.title": "Link Error", "mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.", "mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.", + "mobile.server_link.unreachable_user.error": "This link belongs to a deleted user.", "mobile.server_ssl.error.text": "The certificate from {host} is not trusted.\n\nPlease contact your System Administrator to resolve the certificate issues and allow connections to this server.", "mobile.server_ssl.error.title": "Untrusted Certificate", "mobile.server_upgrade.alert_description": "This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.",