diff --git a/app/components/markdown/markdown_link/index.js b/app/components/markdown/markdown_link/index.js index e30878f02..881c7fa90 100644 --- a/app/components/markdown/markdown_link/index.js +++ b/app/components/markdown/markdown_link/index.js @@ -5,6 +5,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {getConfig, getCurrentUrl} from '@mm-redux/selectors/entities/general'; +import {getCurrentTeam} from '@mm-redux/selectors/entities/teams'; import {handleSelectChannelByName} from 'app/actions/views/channel'; import MarkdownLink from './markdown_link'; @@ -13,6 +14,7 @@ function mapStateToProps(state) { return { serverURL: getCurrentUrl(state), siteURL: getConfig(state).SiteURL, + currentTeamName: getCurrentTeam(state)?.name, }; } diff --git a/app/components/markdown/markdown_link/markdown_link.js b/app/components/markdown/markdown_link/markdown_link.js index 67aa5ca77..be3ea8070 100644 --- a/app/components/markdown/markdown_link/markdown_link.js +++ b/app/components/markdown/markdown_link/markdown_link.js @@ -15,7 +15,7 @@ import {getCurrentServerUrl} from '@init/credentials'; import BottomSheet from '@utils/bottom_sheet'; import {errorBadChannel} from '@utils/draft'; import {preventDoubleTap} from '@utils/tap'; -import {matchDeepLink, normalizeProtocol, tryOpenURL} from '@utils/url'; +import {matchDeepLink, normalizeProtocol, tryOpenURL, PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import mattermostManaged from 'app/mattermost_managed'; @@ -29,6 +29,7 @@ export default class MarkdownLink extends PureComponent { onPermalinkPress: PropTypes.func, serverURL: PropTypes.string, siteURL: PropTypes.string.isRequired, + currentTeamName: PropTypes.string, }; static defaultProps = { @@ -61,7 +62,11 @@ export default class MarkdownLink extends PureComponent { const {intl} = this.context; this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel.bind(null, intl)); } else if (match.type === DeepLinkTypes.PERMALINK) { - onPermalinkPress(match.postId, match.teamName); + if (match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { + onPermalinkPress(match.postId, this.props.currentTeamName); + } else { + onPermalinkPress(match.postId, match.teamName); + } } } else { const onError = () => { diff --git a/app/components/post_list/index.js b/app/components/post_list/index.js index 535602439..3c1580a8f 100644 --- a/app/components/post_list/index.js +++ b/app/components/post_list/index.js @@ -8,6 +8,7 @@ import {closePermalink, showPermalink} from '@actions/views/permalink'; import {getConfig, getCurrentUrl} from '@mm-redux/selectors/entities/general'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {makePreparePostIdsForPostList, START_OF_NEW_MESSAGES} from '@mm-redux/utils/post_list'; +import {getCurrentTeam} from '@mm-redux/selectors/entities/teams'; import {handleSelectChannelByName, refreshChannelWithRetry} from 'app/actions/views/channel'; import {setDeepLinkURL} from 'app/actions/views/root'; @@ -30,6 +31,7 @@ function makeMapStateToProps() { serverURL: getCurrentUrl(state), siteURL: getConfig(state).SiteURL, theme: getTheme(state), + currentTeamName: getCurrentTeam(state)?.name, }; }; } diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index 85c800d13..ee1960682 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -16,7 +16,7 @@ import Post from 'app/components/post'; import {DeepLinkTypes, ListTypes, NavigationTypes} from '@constants'; import mattermostManaged from 'app/mattermost_managed'; import {makeExtraData} from 'app/utils/list_view'; -import {matchDeepLink} from 'app/utils/url'; +import {matchDeepLink, PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from 'app/utils/url'; import telemetry from 'app/telemetry'; import DateHeader from './date_header'; @@ -71,6 +71,7 @@ export default class PostList extends PureComponent { location: PropTypes.string, scrollViewNativeID: PropTypes.string, showMoreMessagesButton: PropTypes.bool, + currentTeamName: PropTypes.string, }; static defaultProps = { @@ -81,6 +82,7 @@ export default class PostList extends PureComponent { siteURL: '', postIds: [], showMoreMessagesButton: false, + currentTeamName: '', }; static contextTypes = { @@ -181,7 +183,7 @@ export default class PostList extends PureComponent { }; handleDeepLink = (url) => { - const {serverURL, siteURL} = this.props; + const {serverURL, siteURL, currentTeamName} = this.props; const match = matchDeepLink(url, serverURL, siteURL); @@ -190,7 +192,11 @@ export default class PostList extends PureComponent { 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); + if (match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { + this.handlePermalinkPress(match.postId, currentTeamName); + } else { + this.handlePermalinkPress(match.postId, match.teamName); + } } } else { const {formatMessage} = this.context.intl; diff --git a/app/utils/url.js b/app/utils/url.js index e4b1618ec..3917564f5 100644 --- a/app/utils/url.js +++ b/app/utils/url.js @@ -102,6 +102,8 @@ export function getScheme(url) { return match && match[1]; } +export const PERMALINK_GENERIC_TEAM_NAME_REDIRECT = '_redirect'; + export function matchDeepLink(url, serverURL, siteURL) { if (!url || (!serverURL && !siteURL)) { return null; diff --git a/detox/e2e/support/server_api/channel.js b/detox/e2e/support/server_api/channel.js index f5643d3d6..0e231a8f4 100644 --- a/detox/e2e/support/server_api/channel.js +++ b/detox/e2e/support/server_api/channel.js @@ -40,6 +40,42 @@ export const apiCreateChannel = async ({teamId = null, type = 'O', prefix = 'cha } }; +/** + * Create a direct message channel. + * See https://api.mattermost.com/#tag/channels/paths/~1channels~1direct/post + * @param {Array} userIds - the two user IDs to be in the direct message + */ +export const apiCreateDirectChannel = async (userIds = []) => { + try { + const response = await client.post( + '/api/v4/channels/direct', + userIds, + ); + + return {channel: response.data}; + } catch (err) { + return getResponseFromError(err); + } +}; + +/** + * Create a group message channel. + * See https://api.mattermost.com/#tag/channels/paths/~1channels~1group/post + * @param {Array} userIds - user IDs to be in the group message channel + */ +export const apiCreateGroupChannel = async (userIds = []) => { + try { + const response = await client.post( + '/api/v4/channels/group', + userIds, + ); + + return {channel: response.data}; + } catch (err) { + return getResponseFromError(err); + } +}; + /** * Get a channel by name and team name. * See https://api.mattermost.com/#tag/channels/paths/~1teams~1name~1{team_name}~1channels~1name~1{channel_name}/get @@ -129,6 +165,8 @@ function generateRandomChannel(teamId, type, prefix) { export const Channel = { apiAddUserToChannel, apiCreateChannel, + apiCreateDirectChannel, + apiCreateGroupChannel, apiDeleteUserFromChannel, apiGetChannelByName, apiGetChannelsForUser, diff --git a/detox/e2e/support/server_api/preference.js b/detox/e2e/support/server_api/preference.js index a84e7cfba..4a1c13f2d 100644 --- a/detox/e2e/support/server_api/preference.js +++ b/detox/e2e/support/server_api/preference.js @@ -35,6 +35,23 @@ export const apiSaveUserPreferences = async (userId, preferences = []) => { } }; +/** + * Save the user's favorite channel preference. + * @param {string} userId - the user ID + * @param {string} channelId - the channel id to be favorited + * @return {string} returns {status} on success or {error, status} on error + */ +export const apiSaveFavoriteChannelPreference = (userId, channelId) => { + const preference = { + user_id: userId, + category: 'favorite_channel', + name: channelId, + value: 'true', + }; + + return apiSaveUserPreferences(userId, [preference]); +}; + /** * Save the user's teams order preference. * @param {string} userId - the user ID @@ -54,6 +71,7 @@ export const apiSaveTeamsOrderPreference = (userId, orderedTeamIds = []) => { export const Preference = { apiSaveUserPreferences, + apiSaveFavoriteChannelPreference, apiSaveTeamsOrderPreference, }; diff --git a/detox/e2e/support/ui/component/channels_list.js b/detox/e2e/support/ui/component/channels_list.js index b541f1869..ff34da000 100644 --- a/detox/e2e/support/ui/component/channels_list.js +++ b/detox/e2e/support/ui/component/channels_list.js @@ -1,8 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import SearchBar from './search_bar'; + class ChannelsList { testID = { + channelsListPrefix: 'main.sidebar.channels_list.', channelsList: 'main.sidebar.channels_list.list', channelItem: 'main.sidebar.channels_list.list.channel_item', channelItemDisplayName: 'main.sidebar.channels_list.list.channel_item.display_name', @@ -22,6 +25,12 @@ class ChannelsList { switchTeamsButtonBadgeUnreadCount = element(by.id(this.testID.switchTeamsButtonBadgeUnreadCount)); switchTeamsButtonBadgeUnreadIndicator = element(by.id(this.testID.switchTeamsButtonBadgeUnreadIndicator)); + // convenience props + searchBar = SearchBar.getSearchBar(this.testID.channelsListPrefix); + searchInput = SearchBar.getSearchInput(this.testID.channelsListPrefix); + cancelButton = SearchBar.getCancelButton(this.testID.channelsListPrefix); + clearButton = SearchBar.getClearButton(this.testID.channelsListPrefix); + getChannelItem = (channelId, displayName) => { const channelItemTestID = `${this.testID.channelItem}.${channelId}`; const baseMatcher = by.id(channelItemTestID); diff --git a/detox/e2e/support/ui/component/main_sidebar.js b/detox/e2e/support/ui/component/main_sidebar.js index cf650be01..92344e6d8 100644 --- a/detox/e2e/support/ui/component/main_sidebar.js +++ b/detox/e2e/support/ui/component/main_sidebar.js @@ -18,6 +18,10 @@ class MainSidebar { openMoreDirectMessagesButton = element(by.id(this.testID.openMoreDirectMessagesButton)); // convenience props + searchBar = ChannelsList.searchBar; + searchInput = ChannelsList.searchInput; + cancelButton = ChannelsList.cancelButton; + clearButton = ChannelsList.clearButton; channelsList = ChannelsList.channelsList; filteredChannelsList = ChannelsList.filteredChannelsList; switchTeamsButton = ChannelsList.switchTeamsButton; diff --git a/detox/e2e/test/messaging/permalink.e2e.js b/detox/e2e/test/messaging/permalink.e2e.js new file mode 100644 index 000000000..f30dd2e1a --- /dev/null +++ b/detox/e2e/test/messaging/permalink.e2e.js @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// ******************************************************************* +// - [#] indicates a test step (e.g. # Go to a screen) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element testID when selecting an element. Create one if none. +// ******************************************************************* + +import {MainSidebar} from '@support/ui/component'; +import {ChannelScreen, PermalinkScreen} from '@support/ui/screen'; +import {User, Setup, Channel, Post} from '@support/server_api'; + +describe('Permalink', () => { + let testUser; + let testChannel; + let townSquareChannel; + + beforeAll(async () => { + const {user, team, channel} = await Setup.apiInit(); + testUser = user; + testChannel = channel; + ({channel: townSquareChannel} = await Channel.apiGetChannelByName(team.name, 'town-square')); + + // # Open channel screen + await ChannelScreen.open(user); + }); + + afterAll(async () => { + await ChannelScreen.logout(); + }); + + const expectPermalinkTargetMessage = async (permalinkTargetPost, permalinkTargetChannel) => { + // # Post a message in the test channel referencing the given permalink. + const permalinkLabel = `permalink-${Date.now().toString()}`; + const permalinkMessage = `[${permalinkLabel}](/_redirect/pl/${permalinkTargetPost.id})`; + await Post.apiCreatePost({ + channelId: testChannel.id, + message: permalinkMessage, + }); + + // # Go to test channel + await ChannelScreen.openMainSidebar(); + await MainSidebar.getChannelByDisplayName(testChannel.display_name).tap(); + await ChannelScreen.toBeVisible(); + + // # Tap the channel permalink + await element(by.text(permalinkLabel)).tap({x: 5, y: 10}); + + // * Verify permalink post list has the expected target message + await PermalinkScreen.toBeVisible(); + const {postListPostItem: permalinkPostItem} = await PermalinkScreen.getPostListPostItem(permalinkTargetPost.id, permalinkTargetPost.message); + await expect(permalinkPostItem).toBeVisible(); + + // # Dismiss the permalink screen by jumping to recent messages + await PermalinkScreen.jumpToRecentMessages(); + + // * Verify user is on channel where message is posted + await expect(ChannelScreen.channelNavBarTitle).toHaveText(permalinkTargetChannel.display_name); + const {postListPostItem: channelPostItem} = await ChannelScreen.getPostListPostItem(permalinkTargetPost.id, permalinkTargetPost.message); + await expect(channelPostItem).toBeVisible(); + }; + + it('MM-T3805_1 should support _redirect to public channel post', async () => { + // # Post a test message in a public channel + const permalinkTargetMessage = 'post in Town Square'; + const permalinkTargetPost = await Post.apiCreatePost({ + channelId: townSquareChannel.id, + message: permalinkTargetMessage, + }); + + await expectPermalinkTargetMessage(permalinkTargetPost.post, townSquareChannel); + }); + + it('MM-T3805_2 should support _redirect to DM post', async () => { + // # Post a test message in a DM + const {user: dmOtherUser} = await User.apiCreateUser({prefix: 'testchannel-1'}); + const {channel: directMessageChannel} = await Channel.apiCreateDirectChannel([testUser.id, dmOtherUser.id]); + const permalinkTargetMessage = 'post in DM'; + const permalinkTargetPost = await Post.apiCreatePost({ + channelId: directMessageChannel.id, + message: permalinkTargetMessage, + }); + + await expectPermalinkTargetMessage(permalinkTargetPost.post, directMessageChannel); + }); +}); diff --git a/detox/e2e/test/smoke_test/channels.e2e.js b/detox/e2e/test/smoke_test/channels.e2e.js new file mode 100644 index 000000000..84a32c327 --- /dev/null +++ b/detox/e2e/test/smoke_test/channels.e2e.js @@ -0,0 +1,151 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// ******************************************************************* +// - [#] indicates a test step (e.g. # Go to a screen) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element testID when selecting an element. Create one if none. +// ******************************************************************* + +import {MainSidebar} from '@support/ui/component'; +import {ChannelScreen} from '@support/ui/screen'; +import { + Channel, + Post, + Preference, + Setup, + Team, + User, +} from '@support/server_api'; + +describe('Channels', () => { + let testMessage; + let unreadChannel; + let favoriteChannel; + let publicChannel; + let privateChannel; + let nonJoinedChannel; + let directMessageChannel; + let dmOtherUser; + let nonDmOtherUser; + const { + channelNavBarTitle, + closeMainSidebar, + openMainSidebar, + } = ChannelScreen; + const { + getChannelByDisplayName, + getFilteredChannelByDisplayName, + hasChannelDisplayNameAtIndex, + hasFilteredChannelDisplayNameAtIndex, + searchInput, + } = MainSidebar; + + beforeAll(async () => { + const {user, channel, team} = await Setup.apiInit(); + unreadChannel = channel; + testMessage = `Mention @${user.username}`; + await Post.apiCreatePost({ + channelId: unreadChannel.id, + message: testMessage, + }); + + ({channel: favoriteChannel} = await Channel.apiCreateChannel({type: 'O', prefix: '4-favorite-channel', teamId: team.id})); + await Channel.apiAddUserToChannel(user.id, favoriteChannel.id); + await Preference.apiSaveFavoriteChannelPreference(user.id, favoriteChannel.id); + + ({channel: publicChannel} = await Channel.apiCreateChannel({type: 'O', prefix: '3-public-channel', teamId: team.id})); + await Channel.apiAddUserToChannel(user.id, publicChannel.id); + + ({channel: privateChannel} = await Channel.apiCreateChannel({type: 'P', prefix: '2-private-channel', teamId: team.id})); + await Channel.apiAddUserToChannel(user.id, privateChannel.id); + + ({channel: nonJoinedChannel} = await Channel.apiCreateChannel({type: 'O', prefix: '1-non-joined-channel', teamId: team.id})); + + ({user: dmOtherUser} = await User.apiCreateUser({prefix: 'testchannel-1'})); + ({channel: directMessageChannel} = await Channel.apiCreateDirectChannel([user.id, dmOtherUser.id])); + await Post.apiCreatePost({ + channelId: directMessageChannel.id, + message: testMessage, + }); + + ({user: nonDmOtherUser} = await User.apiCreateUser({prefix: 'testchannel-2'})); + await Team.apiAddUserToTeam(nonDmOtherUser.id, team.id); + + // # Open channel screen + await ChannelScreen.open(user); + }); + + afterAll(async () => { + await ChannelScreen.logout(); + }); + + it('MM-T3184 should display unfiltered channels list', async () => { + // # Open main sidebar + await openMainSidebar(); + + // * Verify order when all channels are unread + await hasChannelDisplayNameAtIndex(0, privateChannel.display_name); + await hasChannelDisplayNameAtIndex(1, publicChannel.display_name); + await hasChannelDisplayNameAtIndex(2, favoriteChannel.display_name); + await hasChannelDisplayNameAtIndex(3, unreadChannel.display_name); + await hasChannelDisplayNameAtIndex(4, dmOtherUser.username); + await hasChannelDisplayNameAtIndex(5, 'Off-Topic'); + await hasChannelDisplayNameAtIndex(6, 'Town Square'); + await expect(element(by.id(nonJoinedChannel.display_name))).not.toBeVisible(); + await expect(element(by.id(nonDmOtherUser.username))).not.toBeVisible(); + + // # Visit private, public, favorite, and direct message channels + await getChannelByDisplayName(privateChannel.display_name).tap(); + await openMainSidebar(); + await getChannelByDisplayName(publicChannel.display_name).tap(); + await openMainSidebar(); + await getChannelByDisplayName(favoriteChannel.display_name).tap(); + await openMainSidebar(); + await getChannelByDisplayName(dmOtherUser.username).tap(); + await openMainSidebar(); + + // * Verify order when all channels are read except for unread channel + await hasChannelDisplayNameAtIndex(0, unreadChannel.display_name); + await hasChannelDisplayNameAtIndex(1, favoriteChannel.display_name); + await hasChannelDisplayNameAtIndex(2, publicChannel.display_name); + await hasChannelDisplayNameAtIndex(3, 'Off-Topic'); + await hasChannelDisplayNameAtIndex(4, 'Town Square'); + await hasChannelDisplayNameAtIndex(5, privateChannel.display_name); + await hasChannelDisplayNameAtIndex(6, dmOtherUser.username); + await expect(element(by.id(nonJoinedChannel.display_name))).not.toBeVisible(); + await expect(element(by.id(nonDmOtherUser.username))).not.toBeVisible(); + + // # Close main sidebar + await closeMainSidebar(); + }); + + it('MM-T3186 should display filtered channels list and be able to change channels', async () => { + // # Open main sidebar + await openMainSidebar(); + + // # Enter search term + await searchInput.typeText('channel'); + await searchInput.tapBackspaceKey(); + + // * Verify order when channels list is filtered + await hasFilteredChannelDisplayNameAtIndex(0, unreadChannel.display_name); + await hasFilteredChannelDisplayNameAtIndex(1, dmOtherUser.username); + await hasFilteredChannelDisplayNameAtIndex(2, privateChannel.display_name); + await hasFilteredChannelDisplayNameAtIndex(3, publicChannel.display_name); + await hasFilteredChannelDisplayNameAtIndex(4, favoriteChannel.display_name); + await hasFilteredChannelDisplayNameAtIndex(5, nonDmOtherUser.username); + await hasFilteredChannelDisplayNameAtIndex(6, nonJoinedChannel.display_name); + await expect(element(by.text('Off-Topic'))).not.toBeVisible(); + await expect(element(by.text('Town Square'))).not.toBeVisible(); + + // # Tap a channel from filtered list + await getFilteredChannelByDisplayName(unreadChannel.display_name).tap(); + + // * Verify filtered channel opens + await expect(channelNavBarTitle).toHaveText(unreadChannel.display_name); + + // # Close main sidebar + await closeMainSidebar(); + }); +});