diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index 423839941..2a62d4bcf 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -8,6 +8,7 @@ import {ViewTypes} from 'app/constants'; import {UserTypes} from 'mattermost-redux/action_types'; import { fetchMyChannelsAndMembers, + getChannelByNameAndTeamName, markChannelAsRead, selectChannel, leaveChannel as serviceLeaveChannel, @@ -373,6 +374,20 @@ export function handleSelectChannel(channelId) { }; } +export function handleSelectChannelByName(channelName, teamName) { + return async (dispatch, getState) => { + const state = getState(); + const {teams: currentTeams, currentTeamId} = state.entities.teams; + const currentTeamName = currentTeams[currentTeamId].name; + const {data: channel} = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName)); + const currentChannelId = getCurrentChannelId(state); + if (channel && currentChannelId !== channel.id) { + dispatch(setChannelDisplayName(channel.display_name)); + dispatch(handleSelectChannel(channel.id)); + } + }; +} + export function handlePostDraftChanged(channelId, draft) { return async (dispatch, getState) => { dispatch({ diff --git a/app/actions/views/root.test.js b/app/actions/views/root.test.js new file mode 100644 index 000000000..bb1adcee7 --- /dev/null +++ b/app/actions/views/root.test.js @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import {setDeepLinkURL} from './root'; + +const mockStore = configureStore([thunk]); + +describe('Actions.Views.Root', () => { + const store = mockStore(); + + test('should set deep link URL', async () => { + const url = 'https://test-url.com/team-name/pl/pl-id'; + const action = { + type: 'SET_DEEP_LINK_URL', + url, + }; + await store.dispatch(setDeepLinkURL(url)); + expect(store.getActions()).toEqual([action]); + }); +}); diff --git a/app/components/markdown/markdown_link/index.js b/app/components/markdown/markdown_link/index.js index e9d171ec8..b280f3645 100644 --- a/app/components/markdown/markdown_link/index.js +++ b/app/components/markdown/markdown_link/index.js @@ -1,9 +1,11 @@ // 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 {getConfig, getCurrentUrl} from 'mattermost-redux/selectors/entities/general'; +import {handleSelectChannelByName} from 'app/actions/views/channel'; import MarkdownLink from './markdown_link'; @@ -14,4 +16,12 @@ function mapStateToProps(state) { }; } -export default connect(mapStateToProps)(MarkdownLink); +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + handleSelectChannelByName, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(MarkdownLink); diff --git a/app/components/markdown/markdown_link/markdown_link.js b/app/components/markdown/markdown_link/markdown_link.js index 8aae75fa1..ed76eaf4f 100644 --- a/app/components/markdown/markdown_link/markdown_link.js +++ b/app/components/markdown/markdown_link/markdown_link.js @@ -8,15 +8,19 @@ import urlParse from 'url-parse'; import {intlShape} from 'react-intl'; import CustomPropTypes from 'app/constants/custom_prop_types'; +import {DeepLinkTypes} from 'app/constants'; import mattermostManaged from 'app/mattermost_managed'; import BottomSheet from 'app/utils/bottom_sheet'; import {preventDoubleTap} from 'app/utils/tap'; -import {matchPermalink, normalizeProtocol} from 'app/utils/url'; +import {matchDeepLink, normalizeProtocol} from 'app/utils/url'; import Config from 'assets/config'; export default class MarkdownLink extends PureComponent { static propTypes = { + actions: PropTypes.shape({ + handleSelectChannelByName: PropTypes.func.isRequired, + }).isRequired, children: CustomPropTypes.Children.isRequired, href: PropTypes.string.isRequired, onPermalinkPress: PropTypes.func, @@ -40,12 +44,13 @@ export default class MarkdownLink extends PureComponent { return; } - const match = matchPermalink(url, serverURL) || matchPermalink(url, siteURL) || matchPermalink(url, ''); - + const match = matchDeepLink(url, serverURL, siteURL); if (match) { - const teamName = match[1]; - const postId = match[2]; - onPermalinkPress(postId, teamName); + if (match.type === DeepLinkTypes.CHANNEL) { + this.props.actions.handleSelectChannelByName(match.channelName, match.teamName); + } else if (match.type === DeepLinkTypes.PERMALINK) { + onPermalinkPress(match.postId, match.teamName); + } } else { Linking.canOpenURL(url).then((supported) => { if (supported) { diff --git a/app/components/post_list/__snapshots__/post_list.test.js.snap b/app/components/post_list/__snapshots__/post_list.test.js.snap new file mode 100644 index 000000000..48f9e4807 --- /dev/null +++ b/app/components/post_list/__snapshots__/post_list.test.js.snap @@ -0,0 +1,145 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PostList setting channel deep link 1`] = ` + +`; + +exports[`PostList setting permalink deep link 1`] = ` + +`; + +exports[`PostList should match snapshot 1`] = ` + +`; diff --git a/app/components/post_list/index.js b/app/components/post_list/index.js index 1da2e0fec..51c4e75e2 100644 --- a/app/components/post_list/index.js +++ b/app/components/post_list/index.js @@ -8,7 +8,7 @@ import {selectFocusedPostId} from 'mattermost-redux/actions/posts'; import {getConfig, getCurrentUrl} from 'mattermost-redux/selectors/entities/general'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {loadChannelsByTeamName, refreshChannelWithRetry} from 'app/actions/views/channel'; +import {handleSelectChannelByName, loadChannelsByTeamName, refreshChannelWithRetry} from 'app/actions/views/channel'; import {setDeepLinkURL} from 'app/actions/views/root'; import {makePreparePostIdsForPostList, START_OF_NEW_MESSAGES} from 'app/selectors/post_list'; @@ -37,6 +37,7 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + handleSelectChannelByName, loadChannelsByTeamName, refreshChannelWithRetry, selectFocusedPostId, diff --git a/app/components/post_list/post_list.test.js b/app/components/post_list/post_list.test.js new file mode 100644 index 000000000..1e12cab13 --- /dev/null +++ b/app/components/post_list/post_list.test.js @@ -0,0 +1,59 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import PostList from './post_list.ios.js'; +import Preferences from 'mattermost-redux/constants/preferences'; + +jest.useFakeTimers(); + +describe('PostList', () => { + const serverURL = 'https://server-url.fake'; + const baseProps = { + actions: { + handleSelectChannelByName: jest.fn(), + loadChannelsByTeamName: jest.fn(), + refreshChannelWithRetry: jest.fn(), + selectFocusedPostId: jest.fn(), + setDeepLinkURL: jest.fn(), + }, + deepLinkURL: '', + navigator: { + showModal: jest.fn(), + }, + postIds: ['post-id-1', 'post-id-2'], + serverURL, + siteURL: 'https://site-url.fake', + theme: Preferences.THEMES.default, + }; + + const deepLinks = { + permalink: serverURL + '/team-name/pl/pl-id', + channel: serverURL + '/team-name/channels/channel-name', + }; + + const wrapper = shallow( + + ); + + test('should match snapshot', () => { + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('setting permalink deep link', () => { + wrapper.setProps({deepLinkURL: deepLinks.permalink}); + expect(baseProps.actions.setDeepLinkURL).toHaveBeenCalled(); + expect(baseProps.actions.selectFocusedPostId).toHaveBeenCalled(); + expect(baseProps.navigator.showModal).toHaveBeenCalled(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('setting channel deep link', () => { + wrapper.setProps({deepLinkURL: deepLinks.channel}); + expect(baseProps.actions.setDeepLinkURL).toHaveBeenCalled(); + expect(baseProps.actions.handleSelectChannelByName).toHaveBeenCalled(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/components/post_list/post_list_base.js b/app/components/post_list/post_list_base.js index 3a26237e5..aadbc2087 100644 --- a/app/components/post_list/post_list_base.js +++ b/app/components/post_list/post_list_base.js @@ -5,10 +5,11 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import Post from 'app/components/post'; +import {DeepLinkTypes} from 'app/constants'; import {START_OF_NEW_MESSAGES} from 'app/selectors/post_list'; import mattermostManaged from 'app/mattermost_managed'; import {changeOpacity} from 'app/utils/theme'; -import {matchPermalink} from 'app/utils/url'; +import {matchDeepLink} from 'app/utils/url'; import DateHeader from './date_header'; import {isDateLine} from './date_header/utils'; @@ -17,6 +18,7 @@ import NewMessagesDivider from './new_messages_divider'; export default class PostListBase extends PureComponent { static propTypes = { actions: PropTypes.shape({ + handleSelectChannelByName: PropTypes.func.isRequired, loadChannelsByTeamName: PropTypes.func.isRequired, refreshChannelWithRetry: PropTypes.func.isRequired, selectFocusedPostId: PropTypes.func.isRequired, @@ -82,12 +84,13 @@ export default class PostListBase extends PureComponent { handleDeepLink = (url) => { const {serverURL, siteURL} = this.props; - const match = matchPermalink(url, serverURL) || matchPermalink(url, siteURL); - + const match = matchDeepLink(url, serverURL, siteURL); if (match) { - const teamName = match[1]; - const postId = match[2]; - this.handlePermalinkPress(postId, teamName); + if (match.type === DeepLinkTypes.CHANNEL) { + this.props.actions.handleSelectChannelByName(match.channelName, match.teamName); + } else if (match.type === DeepLinkTypes.PERMALINK) { + this.handlePermalinkPress(match.postId, match.teamName); + } } }; diff --git a/app/constants/deep_linking.js b/app/constants/deep_linking.js new file mode 100644 index 000000000..f3ac632db --- /dev/null +++ b/app/constants/deep_linking.js @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export default { + CHANNEL: 'channel', + PERMALINK: 'permalink', + OTHER: 'other', +}; diff --git a/app/constants/index.js b/app/constants/index.js index 575e5f161..9182d6788 100644 --- a/app/constants/index.js +++ b/app/constants/index.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import DeepLinkTypes from './deep_linking'; import DeviceTypes from './device'; import ListTypes from './list'; import NavigationTypes from './navigation'; @@ -8,6 +9,7 @@ import PermissionTypes from './permissions'; import ViewTypes, {UpgradeTypes} from './view'; export { + DeepLinkTypes, DeviceTypes, ListTypes, NavigationTypes, diff --git a/app/utils/url.js b/app/utils/url.js index 4881437db..ef006979d 100644 --- a/app/utils/url.js +++ b/app/utils/url.js @@ -6,6 +6,8 @@ import {escapeRegex} from './markdown'; import {Files} from 'mattermost-redux/constants'; +import {DeepLinkTypes} from 'app/constants'; + const ytRegex = /(?:http|https):\/\/(?:www\.|m\.)?(?:(?:youtube\.com\/(?:(?:v\/)|(?:(?:watch|embed\/watch)(?:\/|.*v=))|(?:embed\/)|(?:user\/[^/]+\/u\/[0-9]\/)))|(?:youtu\.be\/))([^#&?]*)/; export function isValidUrl(url = '') { @@ -96,8 +98,20 @@ export function getScheme(url) { return match && match[1]; } -export function matchPermalink(link, rootURL) { - return new RegExp('^' + escapeRegex(rootURL) + '\\/([^\\/]+)\\/pl\\/(\\w+)').exec(link); +export function matchDeepLink(url, serverURL, siteURL) { + const linkRoot = `(?:${escapeRegex(serverURL)}|${escapeRegex(siteURL)})?`; + + let match = new RegExp('^' + linkRoot + '\\/([^\\/]+)\\/channels\\/(\\S+)').exec(url); + if (match) { + return {type: DeepLinkTypes.CHANNEL, teamName: match[1], channelName: match[2]}; + } + + match = new RegExp('^' + linkRoot + '\\/([^\\/]+)\\/pl\\/(\\w+)').exec(url); + if (match) { + return {type: DeepLinkTypes.PERMALINK, teamName: match[1], postId: match[2]}; + } + + return null; } export function getYouTubeVideoId(link) {