From d0d91038576f8469dc7c88035e47f3583131d68f Mon Sep 17 00:00:00 2001 From: enahum Date: Wed, 7 Mar 2018 14:48:31 +0000 Subject: [PATCH] [MM-9374] Open Permalinks in app (#1449) * [MM-9374] Open Permalinks in app * Feedback review * Upgrade mattermost-redux * Fix eslint * Navigation fixes * Fixing style of merged lines --- app/actions/views/channel.js | 13 + app/components/at_mention/at_mention.js | 13 +- app/components/channel_intro/channel_intro.js | 12 +- app/components/markdown/markdown.js | 2 + .../markdown/markdown_link/index.js | 17 + .../{ => markdown_link}/markdown_link.js | 36 +- app/components/post/post.js | 21 +- app/components/post_body/post_body.js | 90 ++-- .../post_body_additional_content.js | 3 + app/components/post_list/index.js | 9 +- app/components/post_list/post_list.js | 58 ++- app/components/post_textbox/index.js | 4 +- app/components/search_preview/index.js | 40 -- .../search_preview/search_preview.js | 287 ----------- app/components/slack_attachments/index.js | 14 +- .../slack_attachments/slack_attachment.js | 6 + app/screens/channel_info/channel_info.js | 87 +++- .../channel_info/channel_info_header.js | 4 + app/screens/channel_info/index.js | 4 + app/screens/index.js | 2 + app/screens/permalink/index.js | 76 +++ app/screens/permalink/permalink.js | 455 ++++++++++++++++++ app/screens/search/index.js | 17 +- app/screens/search/search.js | 134 ++---- .../search_result_post/search_result_post.js | 2 + app/screens/user_profile/user_profile.js | 20 +- app/utils/markdown.js | 4 + assets/base/i18n/en.json | 3 +- 28 files changed, 887 insertions(+), 546 deletions(-) create mode 100644 app/components/markdown/markdown_link/index.js rename app/components/markdown/{ => markdown_link}/markdown_link.js (73%) delete mode 100644 app/components/search_preview/index.js delete mode 100644 app/components/search_preview/search_preview.js create mode 100644 app/screens/permalink/index.js create mode 100644 app/screens/permalink/permalink.js diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index 043f9efea..576e80c70 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -19,6 +19,7 @@ import {getTeamMembersByIds} from 'mattermost-redux/actions/teams'; import {getProfilesInChannel} from 'mattermost-redux/actions/users'; import {General, Preferences} from 'mattermost-redux/constants'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {getTeamByName} from 'mattermost-redux/selectors/entities/teams'; import { getChannelByName, @@ -40,6 +41,18 @@ export function loadChannelsIfNecessary(teamId) { }; } +export function loadChannelsByTeamName(teamName) { + return async (dispatch, getState) => { + const state = getState(); + const {currentTeamId} = state.entities.teams; + const team = getTeamByName(state, teamName); + + if (team && team.id !== currentTeamId) { + await dispatch(fetchMyChannelsAndMembers(team.id)); + } + }; +} + export function loadProfilesAndTeamMembersForDMSidebar(teamId) { return async (dispatch, getState) => { const state = getState(); diff --git a/app/components/at_mention/at_mention.js b/app/components/at_mention/at_mention.js index 2bccf5f6c..5665e05df 100644 --- a/app/components/at_mention/at_mention.js +++ b/app/components/at_mention/at_mention.js @@ -3,7 +3,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {Clipboard, Text} from 'react-native'; +import {Clipboard, Platform, Text} from 'react-native'; import {intlShape} from 'react-intl'; import CustomPropTypes from 'app/constants/custom_prop_types'; @@ -49,8 +49,7 @@ export default class AtMention extends React.PureComponent { goToUserProfile = () => { const {navigator, theme} = this.props; const {intl} = this.context; - - navigator.push({ + const options = { screen: 'UserProfile', title: intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}), animated: true, @@ -64,7 +63,13 @@ export default class AtMention extends React.PureComponent { navBarButtonColor: theme.sidebarHeaderTextColor, screenBackgroundColor: theme.centerChannelBg, }, - }); + }; + + if (Platform.OS === 'ios') { + navigator.push(options); + } else { + navigator.showModal(options); + } }; getUserDetailsFromMentionName(props) { diff --git a/app/components/channel_intro/channel_intro.js b/app/components/channel_intro/channel_intro.js index a0eb1ee53..9b4ffad74 100644 --- a/app/components/channel_intro/channel_intro.js +++ b/app/components/channel_intro/channel_intro.js @@ -4,6 +4,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { + Platform, Text, TouchableOpacity, View, @@ -30,8 +31,7 @@ class ChannelIntro extends PureComponent { goToUserProfile = (userId) => { const {intl, navigator, theme} = this.props; - - navigator.push({ + const options = { screen: 'UserProfile', title: intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}), animated: true, @@ -45,7 +45,13 @@ class ChannelIntro extends PureComponent { navBarButtonColor: theme.sidebarHeaderTextColor, screenBackgroundColor: theme.centerChannelBg, }, - }); + }; + + if (Platform.OS === 'ios') { + navigator.push(options); + } else { + navigator.showModal(options); + } }; getDisplayName = (member) => { diff --git a/app/components/markdown/markdown.js b/app/components/markdown/markdown.js index c413debce..4789d98f4 100644 --- a/app/components/markdown/markdown.js +++ b/app/components/markdown/markdown.js @@ -38,6 +38,7 @@ export default class Markdown extends PureComponent { isSearchResult: PropTypes.bool, navigator: PropTypes.object.isRequired, onLongPress: PropTypes.func, + onPermalinkPress: PropTypes.func, onPostPress: PropTypes.func, textStyles: PropTypes.object, theme: PropTypes.object.isRequired, @@ -320,6 +321,7 @@ export default class Markdown extends PureComponent { {children} diff --git a/app/components/markdown/markdown_link/index.js b/app/components/markdown/markdown_link/index.js new file mode 100644 index 000000000..411e09118 --- /dev/null +++ b/app/components/markdown/markdown_link/index.js @@ -0,0 +1,17 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getConfig, getCurrentUrl} from 'mattermost-redux/selectors/entities/general'; + +import MarkdownLink from './markdown_link'; + +function mapStateToProps(state) { + return { + serverURL: getCurrentUrl(state), + siteURL: getConfig(state).SiteURL, + }; +} + +export default connect(mapStateToProps)(MarkdownLink); diff --git a/app/components/markdown/markdown_link.js b/app/components/markdown/markdown_link/markdown_link.js similarity index 73% rename from app/components/markdown/markdown_link.js rename to app/components/markdown/markdown_link/markdown_link.js index 072b08ea2..1f027a92e 100644 --- a/app/components/markdown/markdown_link.js +++ b/app/components/markdown/markdown_link/markdown_link.js @@ -12,6 +12,8 @@ import mattermostManaged from 'app/mattermost_managed'; import Config from 'assets/config'; +import {escapeRegex} from 'app/utils/markdown'; +import {preventDoubleTap} from 'app/utils/tap'; import {normalizeProtocol} from 'app/utils/url'; export default class MarkdownLink extends PureComponent { @@ -19,29 +21,45 @@ export default class MarkdownLink extends PureComponent { children: CustomPropTypes.Children.isRequired, href: PropTypes.string.isRequired, onLongPress: PropTypes.func, + onPermalinkPress: PropTypes.func, + serverURL: PropTypes.string.isRequired, + siteURL: PropTypes.string.isRequired, }; static defaultProps = { onLongPress: () => true, + onPermalinkPress: () => true, }; static contextTypes = { intl: intlShape.isRequired, }; - handlePress = () => { - const url = normalizeProtocol(this.props.href); + handlePress = preventDoubleTap(() => { + const {href, onPermalinkPress, serverURL, siteURL} = this.props; + const url = normalizeProtocol(href); if (!url) { return; } - Linking.canOpenURL(url).then((supported) => { - if (supported) { - Linking.openURL(url); - } - }); - }; + const pattern = new RegExp('^' + escapeRegex(serverURL) + '\\/([^\\/]+)\\/pl\\/(\\w+)'); + const sitePattern = new RegExp('^' + escapeRegex(siteURL) + '\\/([^\\/]+)\\/pl\\/(\\w+)'); + const match = url.match(pattern) || url.match(sitePattern); + + if (!match) { + Linking.canOpenURL(url).then((supported) => { + if (supported) { + Linking.openURL(url); + } + }); + return; + } + + const teamName = match[1]; + const postId = match[2]; + onPermalinkPress(postId, teamName); + }); parseLinkLiteral = (literal) => { let nextLiteral = literal; @@ -54,7 +72,7 @@ export default class MarkdownLink extends PureComponent { const parsed = urlParse(nextLiteral, {}); return parsed.href; - } + }; parseChildren = () => { return Children.map(this.props.children, (child) => { diff --git a/app/components/post/post.js b/app/components/post/post.js index 2d472b118..750e1a0b1 100644 --- a/app/components/post/post.js +++ b/app/components/post/post.js @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import { Alert, Clipboard, + Platform, View, ViewPropTypes, } from 'react-native'; @@ -55,6 +56,7 @@ class Post extends PureComponent { license: PropTypes.object.isRequired, managedConfig: PropTypes.object.isRequired, navigator: PropTypes.object, + onPermalinkPress: PropTypes.func, roles: PropTypes.string, shouldRenderReplyButton: PropTypes.bool, showFullDate: PropTypes.bool, @@ -107,7 +109,7 @@ class Post extends PureComponent { goToUserProfile = () => { const {intl, navigator, post, theme} = this.props; - navigator.push({ + const options = { screen: 'UserProfile', title: intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}), animated: true, @@ -121,7 +123,13 @@ class Post extends PureComponent { navBarButtonColor: theme.sidebarHeaderTextColor, screenBackgroundColor: theme.centerChannelBg, }, - }); + }; + + if (Platform.OS === 'ios') { + navigator.push(options); + } else { + navigator.showModal(options); + } }; autofillUserMention = (username) => { @@ -260,7 +268,6 @@ class Post extends PureComponent { handlePress = preventDoubleTap(() => { const { - isSearchResult, onPress, post, } = this.props; @@ -268,7 +275,7 @@ class Post extends PureComponent { if (!getToolTipVisible()) { if (onPress && post.state !== Posts.POST_DELETED && !isSystemMessage(post) && !isPostPendingOrFailed(post)) { onPress(post); - } else if (!isSearchResult && isPostEphemeral(post)) { + } else if (isPostEphemeral(post) || post.state === Posts.POST_DELETED) { this.onRemovePost(post); } } @@ -320,9 +327,7 @@ class Post extends PureComponent { }; viewUserProfile = preventDoubleTap(() => { - const {isSearchResult} = this.props; - - if (!isSearchResult && !getToolTipVisible()) { + if (!getToolTipVisible()) { this.goToUserProfile(); } }); @@ -355,6 +360,7 @@ class Post extends PureComponent { highlight, isLastReply, isSearchResult, + onPermalinkPress, post, renderReplies, shouldRenderReplyButton, @@ -412,6 +418,7 @@ class Post extends PureComponent { onCopyPermalink={this.handleCopyPermalink} onCopyText={this.handleCopyText} onFailedPostPress={this.handleFailedPostPress} + onPermalinkPress={onPermalinkPress} onPostDelete={this.handlePostDelete} onPostEdit={this.handlePostEdit} onPress={this.handlePress} diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index 646098a37..bd01e7d18 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -51,6 +51,7 @@ class PostBody extends PureComponent { onCopyPermalink: PropTypes.func, onCopyText: PropTypes.func, onFailedPostPress: PropTypes.func, + onPermalinkPress: PropTypes.func, onPostDelete: PropTypes.func, onPostEdit: PropTypes.func, onPress: PropTypes.func, @@ -151,6 +152,7 @@ class PostBody extends PureComponent { message, navigator, onFailedPostPress, + onPermalinkPress, onPostDelete, onPostEdit, onPress, @@ -169,7 +171,7 @@ class PostBody extends PureComponent { const isPendingOrFailedPost = isPending || isFailed; // we should check for the user roles and permissions - if (!isPendingOrFailedPost && !isSearchResult && !isSystemMessage && !isPostEphemeral) { + if (!isPendingOrFailedPost && !isSystemMessage && !isPostEphemeral) { actions.push({ text: formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}), onPress: this.props.onAddReaction, @@ -240,6 +242,7 @@ class PostBody extends PureComponent { isSearchResult={isSearchResult} navigator={navigator} onLongPress={this.showOptionsContext} + onPermalinkPress={onPermalinkPress} onPostPress={onPress} textStyles={textStyles} value={message} @@ -250,61 +253,36 @@ class PostBody extends PureComponent { } if (!hasBeenDeleted) { - if (isSearchResult) { - body = ( - - - {messageComponent} - - {this.renderFileAttachments()} - - - ); - } else { - body = ( - - {messageComponent} - - {this.renderFileAttachments()} - {hasReactions && - - } - - ); - } + body = ( + + {messageComponent} + + {this.renderFileAttachments()} + {!isSearchResult && hasReactions && + + } + + ); } return ( diff --git a/app/components/post_body_additional_content/post_body_additional_content.js b/app/components/post_body_additional_content/post_body_additional_content.js index cf6ce711a..85fea118a 100644 --- a/app/components/post_body_additional_content/post_body_additional_content.js +++ b/app/components/post_body_additional_content/post_body_additional_content.js @@ -37,6 +37,7 @@ export default class PostBodyAdditionalContent extends PureComponent { message: PropTypes.string.isRequired, navigator: PropTypes.object.isRequired, onLongPress: PropTypes.func, + onPermalinkPress: PropTypes.func, openGraphData: PropTypes.object, postId: PropTypes.string.isRequired, postProps: PropTypes.object.isRequired, @@ -160,6 +161,7 @@ export default class PostBodyAdditionalContent extends PureComponent { baseTextStyle, blockStyles, navigator, + onPermalinkPress, textStyles, theme, } = this.props; @@ -176,6 +178,7 @@ export default class PostBodyAdditionalContent extends PureComponent { textStyles={textStyles} theme={theme} onLongPress={this.props.onLongPress} + onPermalinkPress={onPermalinkPress} /> ); } diff --git a/app/components/post_list/index.js b/app/components/post_list/index.js index 28192a9fd..8163f9f05 100644 --- a/app/components/post_list/index.js +++ b/app/components/post_list/index.js @@ -4,11 +4,12 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {refreshChannelWithRetry} from 'app/actions/views/channel'; -import {makePreparePostIdsForPostList, START_OF_NEW_MESSAGES} from 'app/selectors/post_list'; - +import {selectFocusedPostId} from 'mattermost-redux/actions/posts'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {loadChannelsByTeamName, refreshChannelWithRetry} from 'app/actions/views/channel'; +import {makePreparePostIdsForPostList, START_OF_NEW_MESSAGES} from 'app/selectors/post_list'; + import PostList from './post_list'; function makeMapStateToProps() { @@ -31,7 +32,9 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + loadChannelsByTeamName, refreshChannelWithRetry, + selectFocusedPostId, }, dispatch), }; } diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index 7dc38dad2..4cb765ce8 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -4,10 +4,10 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { + FlatList, InteractionManager, Platform, StyleSheet, - FlatList, } from 'react-native'; import ChannelIntro from 'app/components/channel_intro'; @@ -15,6 +15,7 @@ import Post from 'app/components/post'; import {DATE_LINE, START_OF_NEW_MESSAGES} from 'app/selectors/post_list'; import mattermostManaged from 'app/mattermost_managed'; import {makeExtraData} from 'app/utils/list_view'; +import {changeOpacity} from 'app/utils/theme'; import DateHeader from './date_header'; import LoadMorePosts from './load_more_posts'; @@ -30,7 +31,9 @@ const DATE_HEADER_HEIGHT = 28; export default class PostList extends PureComponent { static propTypes = { actions: PropTypes.shape({ + loadChannelsByTeamName: PropTypes.func.isRequired, refreshChannelWithRetry: PropTypes.func.isRequired, + selectFocusedPostId: PropTypes.func.isRequired, }).isRequired, channelId: PropTypes.string, currentUserId: PropTypes.string, @@ -42,6 +45,7 @@ export default class PostList extends PureComponent { loadMore: PropTypes.func, measureCellLayout: PropTypes.bool, navigator: PropTypes.object, + onPermalinkPress: PropTypes.func, onPostPress: PropTypes.func, onRefresh: PropTypes.func, postIds: PropTypes.array.isRequired, @@ -94,13 +98,51 @@ export default class PostList extends PureComponent { mattermostManaged.removeEventListener(this.listenerId); } + handleClosePermalink = () => { + const {actions} = this.props; + actions.selectFocusedPostId(''); + this.showingPermalink = false; + }; + + handlePermalinkPress = (postId, teamName) => { + this.props.actions.loadChannelsByTeamName(teamName); + this.showPermalinkView(postId); + }; + + showPermalinkView = (postId) => { + const {actions, navigator} = this.props; + + actions.selectFocusedPostId(postId); + + if (!this.showingPermalink) { + const options = { + screen: 'Permalink', + animationType: 'none', + backButtonTitle: '', + navigatorStyle: { + navBarHidden: true, + screenBackgroundColor: changeOpacity('#000', 0.2), + modalPresentationStyle: 'overCurrentContext', + }, + passProps: { + isPermalink: true, + onClose: this.handleClosePermalink, + onPermalinkPress: this.handlePermalinkPress, + }, + }; + + this.showingPermalink = true; + navigator.showModal(options); + } + }; + scrollToBottomOffset = () => { InteractionManager.runAfterInteractions(() => { if (this.refs.list) { this.refs.list.scrollToOffset({offset: 0, animated: false}); } }); - } + }; getMeasurementOffset = (index) => { const orderedKeys = Object.keys(this.itemMeasurements).sort((a, b) => { @@ -117,13 +159,12 @@ export default class PostList extends PureComponent { }).slice(0, index); return orderedKeys.map((i) => this.itemMeasurements[i]).reduce((a, b) => a + b, 0); - } + }; scrollListToMessageOffset = () => { const index = this.moreNewMessages ? this.props.postIds.length - 1 : this.newMessagesIndex; - if (index !== -1) { - let offset = this.getMeasurementOffset(index); + let offset = this.getMeasurementOffset(index) - (3 * this.itemMeasurements[index]); const windowHeight = this.state.postListHeight; if (offset < windowHeight) { @@ -148,7 +189,7 @@ export default class PostList extends PureComponent { } }); } - } + }; setManagedConfig = async (config) => { let nextConfig = config; @@ -191,7 +232,7 @@ export default class PostList extends PureComponent { }); } } - } + }; renderItem = ({item, index}) => { if (item === START_OF_NEW_MESSAGES) { @@ -260,6 +301,7 @@ export default class PostList extends PureComponent { renderReplies={renderReplies} isSearchResult={isSearchResult} shouldRenderReplyButton={shouldRenderReplyButton} + onPermalinkPress={this.handlePermalinkPress} onPress={onPostPress} navigator={navigator} managedConfig={managedConfig} @@ -296,7 +338,7 @@ export default class PostList extends PureComponent { this.setState({ postListHeight: height, }); - } + }; render() { const { diff --git a/app/components/post_textbox/index.js b/app/components/post_textbox/index.js index a4454efd9..08ba0a39c 100644 --- a/app/components/post_textbox/index.js +++ b/app/components/post_textbox/index.js @@ -28,7 +28,7 @@ function mapStateToProps(state, ownProps) { const currentChannel = getCurrentChannel(state); let deactivatedChannel = false; - if (currentChannel.type === General.DM_CHANNEL) { + if (currentChannel && currentChannel.type === General.DM_CHANNEL) { const teammate = getChannelMembersForDm(state, currentChannel); if (teammate.length && teammate[0].delete_at) { deactivatedChannel = true; @@ -36,7 +36,7 @@ function mapStateToProps(state, ownProps) { } return { - channelId: ownProps.channelId || currentChannel.id, + channelId: ownProps.channelId || (currentChannel ? currentChannel.id : ''), canUploadFiles: canUploadFilesOnMobile(state), channelIsLoading: state.views.channel.loading, currentUserId: getCurrentUserId(state), diff --git a/app/components/search_preview/index.js b/app/components/search_preview/index.js deleted file mode 100644 index 3fb34d422..000000000 --- a/app/components/search_preview/index.js +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import {bindActionCreators} from 'redux'; -import {connect} from 'react-redux'; - -import {makeGetPostIdsAroundPost} from 'mattermost-redux/selectors/entities/posts'; -import {getPostsAfter, getPostsBefore, getPostThread} from 'mattermost-redux/actions/posts'; -import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; - -import SearchPreview from './search_preview'; - -function makeMapStateToProps() { - const getPostIdsAroundPost = makeGetPostIdsAroundPost(); - const getChannel = makeGetChannel(); - - return function mapStateToProps(state, ownProps) { - const channel = getChannel(state, {id: ownProps.channelId}); - const postIds = getPostIdsAroundPost(state, ownProps.focusedPostId, ownProps.channelId, {postsBeforeCount: 5, postsAfterCount: 5}); - - return { - channelName: channel ? channel.display_name : '', - currentUserId: getCurrentUserId(state), - postIds, - }; - }; -} - -function mapDispatchToProps(dispatch) { - return { - actions: bindActionCreators({ - getPostsAfter, - getPostsBefore, - getPostThread, - }, dispatch), - }; -} - -export default connect(makeMapStateToProps, mapDispatchToProps, null, {withRef: true})(SearchPreview); diff --git a/app/components/search_preview/search_preview.js b/app/components/search_preview/search_preview.js deleted file mode 100644 index ecd519938..000000000 --- a/app/components/search_preview/search_preview.js +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import { - Platform, - Text, - TouchableOpacity, - View, -} from 'react-native'; -import * as Animatable from 'react-native-animatable'; -import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; - -import FormattedText from 'app/components/formatted_text'; -import Loading from 'app/components/loading'; -import PostList from 'app/components/post_list'; -import PostListRetry from 'app/components/post_list_retry'; -import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; - -Animatable.initializeRegistryWithDefinitions({ - growOut: { - from: { - opacity: 1, - scale: 1, - }, - 0.5: { - opacity: 1, - scale: 3, - }, - to: { - opacity: 0, - scale: 5, - }, - }, -}); - -export default class SearchPreview extends PureComponent { - static propTypes = { - actions: PropTypes.shape({ - getPostsAfter: PropTypes.func.isRequired, - getPostsBefore: PropTypes.func.isRequired, - getPostThread: PropTypes.func.isRequired, - }).isRequired, - channelId: PropTypes.string, - channelName: PropTypes.string, - currentUserId: PropTypes.string.isRequired, - focusedPostId: PropTypes.string.isRequired, - navigator: PropTypes.object, - onClose: PropTypes.func, - onPress: PropTypes.func, - postIds: PropTypes.array, - theme: PropTypes.object.isRequired, - }; - - static defaultProps = { - postIds: [], - }; - - constructor(props) { - super(props); - - const {postIds} = props; - let show = false; - if (postIds && postIds.length >= 10) { - show = true; - } - - this.state = { - show, - error: false, - }; - } - - componentDidMount() { - if (!this.state.show) { - this.loadPosts(); - } - } - - handleClose = () => { - if (this.refs.view) { - this.refs.view.zoomOut().then(() => { - if (this.props.onClose) { - this.props.onClose(); - } - }); - } - }; - - handlePress = () => { - const {channelId, channelName, onPress} = this.props; - - if (this.refs.view) { - this.refs.view.growOut().then(() => { - if (onPress) { - onPress(channelId, channelName); - } - }); - } - }; - - loadPosts = async () => { - const {actions, channelId, focusedPostId} = this.props; - - const result = await Promise.all([ - actions.getPostThread(focusedPostId, false), - actions.getPostsBefore(channelId, focusedPostId, 0, 5), - actions.getPostsAfter(channelId, focusedPostId, 0, 5), - ]); - - const error = result.some((res) => Boolean(res.error)); - this.setState({show: true, error}); - }; - - retry = () => { - this.setState({show: false, error: false}); - this.loadPosts(); - }; - - render() { - const { - channelName, - currentUserId, - focusedPostId, - navigator, - postIds, - theme, - } = this.props; - const style = getStyleSheet(theme); - - let postList; - if (this.state.error) { - postList = ( - - ); - } else if (this.state.show) { - postList = ( - - ); - } else { - postList = ; - } - - return ( - - - - - - - - - {channelName} - - - - - {postList} - - - - - - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - container: { - position: 'absolute', - backgroundColor: changeOpacity('#000', 0.3), - height: '100%', - top: 0, - left: 0, - zIndex: 10, - width: '100%', - }, - wrapper: { - flex: 1, - marginBottom: 10, - marginHorizontal: 10, - opacity: 0, - ...Platform.select({ - android: { - marginTop: 10, - }, - ios: { - marginTop: 20, - }, - }), - }, - header: { - alignItems: 'center', - backgroundColor: theme.centerChannelBg, - borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2), - borderBottomWidth: 1, - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - flexDirection: 'row', - height: 44, - paddingRight: 16, - width: '100%', - }, - close: { - justifyContent: 'center', - height: 44, - width: 40, - paddingLeft: 7, - }, - titleContainer: { - alignItems: 'center', - flex: 1, - paddingRight: 40, - }, - title: { - color: theme.centerChannelColor, - fontSize: 17, - fontWeight: '600', - }, - postList: { - backgroundColor: theme.centerChannelBg, - flex: 1, - }, - footer: { - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.buttonBg, - borderBottomLeftRadius: 6, - borderBottomRightRadius: 6, - flexDirection: 'row', - height: 44, - paddingRight: 16, - width: '100%', - }, - jump: { - color: theme.buttonColor, - fontSize: 16, - fontWeight: '600', - textAlignVertical: 'center', - }, - }; -}); diff --git a/app/components/slack_attachments/index.js b/app/components/slack_attachments/index.js index 745000827..7a63401ec 100644 --- a/app/components/slack_attachments/index.js +++ b/app/components/slack_attachments/index.js @@ -17,12 +17,23 @@ export default class SlackAttachments extends PureComponent { postId: PropTypes.string.isRequired, navigator: PropTypes.object.isRequired, onLongPress: PropTypes.func.isRequired, + onPermalinkPress: PropTypes.func, theme: PropTypes.object, textStyles: PropTypes.object, }; render() { - const {attachments, baseTextStyle, blockStyles, navigator, onLongPress, postId, theme, textStyles} = this.props; + const { + attachments, + baseTextStyle, + blockStyles, + navigator, + onLongPress, + onPermalinkPress, + postId, + theme, + textStyles, + } = this.props; const content = []; attachments.forEach((attachment, i) => { @@ -34,6 +45,7 @@ export default class SlackAttachments extends PureComponent { key={'att_' + i} navigator={navigator} onLongPress={onLongPress} + onPermalinkPress={onPermalinkPress} postId={postId} theme={theme} textStyles={textStyles} diff --git a/app/components/slack_attachments/slack_attachment.js b/app/components/slack_attachments/slack_attachment.js index 738dcf4bf..7398eb4f8 100644 --- a/app/components/slack_attachments/slack_attachment.js +++ b/app/components/slack_attachments/slack_attachment.js @@ -31,6 +31,7 @@ export default class SlackAttachment extends PureComponent { navigator: PropTypes.object.isRequired, postId: PropTypes.string.isRequired, onLongPress: PropTypes.func.isRequired, + onPermalinkPress: PropTypes.func, theme: PropTypes.object, textStyles: PropTypes.object, }; @@ -103,6 +104,7 @@ export default class SlackAttachment extends PureComponent { baseTextStyle, blockStyles, navigator, + onPermalinkPress, textStyles, } = this.props; const fields = attachment.fields; @@ -159,6 +161,7 @@ export default class SlackAttachment extends PureComponent { value={(field.value || '')} navigator={navigator} onLongPress={this.props.onLongPress} + onPermalinkPress={onPermalinkPress} /> @@ -212,6 +215,7 @@ export default class SlackAttachment extends PureComponent { blockStyles, textStyles, navigator, + onPermalinkPress, theme, } = this.props; @@ -228,6 +232,7 @@ export default class SlackAttachment extends PureComponent { value={attachment.pretext} navigator={navigator} onLongPress={this.props.onLongPress} + onPermalinkPress={onPermalinkPress} /> ); @@ -341,6 +346,7 @@ export default class SlackAttachment extends PureComponent { value={this.state.text} navigator={navigator} onLongPress={this.props.onLongPress} + onPermalinkPress={onPermalinkPress} /> {moreLess} diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js index 5ff70f47a..ab007e59b 100644 --- a/app/screens/channel_info/channel_info.js +++ b/app/screens/channel_info/channel_info.js @@ -3,7 +3,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {injectIntl, intlShape} from 'react-intl'; +import {intlShape} from 'react-intl'; import { Alert, Platform, @@ -22,9 +22,20 @@ import EventEmitter from 'mattermost-redux/utils/event_emitter'; import ChannelInfoHeader from './channel_info_header'; import ChannelInfoRow from './channel_info_row'; -class ChannelInfo extends PureComponent { +export default class ChannelInfo extends PureComponent { static propTypes = { - intl: intlShape.isRequired, + actions: PropTypes.shape({ + closeDMChannel: PropTypes.func.isRequired, + closeGMChannel: PropTypes.func.isRequired, + deleteChannel: PropTypes.func.isRequired, + getChannelStats: PropTypes.func.isRequired, + leaveChannel: PropTypes.func.isRequired, + loadChannelsByTeamName: PropTypes.func.isRequired, + favoriteChannel: PropTypes.func.isRequired, + unfavoriteChannel: PropTypes.func.isRequired, + getCustomEmojisInText: PropTypes.func.isRequired, + selectFocusedPostId: PropTypes.func.isRequired, + }), canDeleteChannel: PropTypes.bool.isRequired, currentChannel: PropTypes.object.isRequired, currentChannelCreatorName: PropTypes.string, @@ -36,23 +47,17 @@ class ChannelInfo extends PureComponent { isFavorite: PropTypes.bool.isRequired, canManageUsers: PropTypes.bool.isRequired, canEditChannel: PropTypes.bool.isRequired, - actions: PropTypes.shape({ - closeDMChannel: PropTypes.func.isRequired, - closeGMChannel: PropTypes.func.isRequired, - deleteChannel: PropTypes.func.isRequired, - getChannelStats: PropTypes.func.isRequired, - leaveChannel: PropTypes.func.isRequired, - favoriteChannel: PropTypes.func.isRequired, - unfavoriteChannel: PropTypes.func.isRequired, - getCustomEmojisInText: PropTypes.func.isRequired, - }), + }; + + static contextTypes = { + intl: intlShape.isRequired, }; constructor(props) { super(props); this.state = { - isFavorite: this.props.isFavorite, + isFavorite: props.isFavorite, }; } @@ -82,7 +87,8 @@ class ChannelInfo extends PureComponent { }; goToChannelAddMembers = preventDoubleTap(() => { - const {intl, navigator, theme} = this.props; + const {intl} = this.context; + const {navigator, theme} = this.props; navigator.push({ backButtonTitle: '', screen: 'ChannelAddMembers', @@ -98,7 +104,8 @@ class ChannelInfo extends PureComponent { }); goToChannelMembers = preventDoubleTap(() => { - const {canManageUsers, intl, navigator, theme} = this.props; + const {intl} = this.context; + const {canManageUsers, navigator, theme} = this.props; const id = canManageUsers ? 'channel_header.manageMembers' : 'channel_header.viewMembers'; const defaultMessage = canManageUsers ? 'Manage Members' : 'View Members'; @@ -117,7 +124,8 @@ class ChannelInfo extends PureComponent { }); handleChannelEdit = preventDoubleTap(() => { - const {intl, navigator, theme} = this.props; + const {intl} = this.context; + const {navigator, theme} = this.props; const id = 'mobile.channel_info.edit'; const defaultMessage = 'Edit Channel'; @@ -144,7 +152,7 @@ class ChannelInfo extends PureComponent { }; handleDeleteOrLeave = preventDoubleTap((eventType) => { - const {formatMessage} = this.props.intl; + const {formatMessage} = this.context.intl; const channel = this.props.currentChannel; const term = channel.type === General.OPEN_CHANNEL ? formatMessage({id: 'mobile.channel_info.publicChannel', defaultMessage: 'Public Channel'}) : @@ -173,7 +181,7 @@ class ChannelInfo extends PureComponent { const result = await this.props.actions.deleteChannel(channel.id); if (result.error) { alertErrorWithFallback( - this.props.intl, + this.context.intl, result.error, { id: 'mobile.channel_info.delete_failed', @@ -234,6 +242,44 @@ class ChannelInfo extends PureComponent { toggleFavorite(currentChannel.id); }; + handleClosePermalink = () => { + const {actions} = this.props; + actions.selectFocusedPostId(''); + this.showingPermalink = false; + }; + + handlePermalinkPress = (postId, teamName) => { + this.props.actions.loadChannelsByTeamName(teamName); + this.showPermalinkView(postId); + }; + + showPermalinkView = (postId) => { + const {actions, navigator} = this.props; + + actions.selectFocusedPostId(postId); + + if (!this.showingPermalink) { + const options = { + screen: 'Permalink', + animationType: 'none', + backButtonTitle: '', + navigatorStyle: { + navBarHidden: true, + screenBackgroundColor: changeOpacity('#000', 0.2), + modalPresentationStyle: 'overCurrentContext', + }, + passProps: { + isPermalink: true, + onClose: this.handleClosePermalink, + onPermalinkPress: this.handlePermalinkPress, + }, + }; + + this.showingPermalink = true; + navigator.showModal(options); + } + }; + renderViewOrManageMembersRow = () => { const channel = this.props.currentChannel; const isDirectMessage = channel.type === General.DM_CHANNEL; @@ -301,6 +347,7 @@ class ChannelInfo extends PureComponent { header={currentChannel.header} memberCount={currentChannelMemberCount} navigator={navigator} + onPermalinkPress={this.handlePermalinkPress} purpose={currentChannel.purpose} status={status} theme={theme} @@ -443,5 +490,3 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }, }; }); - -export default injectIntl(ChannelInfo); diff --git a/app/screens/channel_info/channel_info_header.js b/app/screens/channel_info/channel_info_header.js index 99d114ff4..6f119620e 100644 --- a/app/screens/channel_info/channel_info_header.js +++ b/app/screens/channel_info/channel_info_header.js @@ -23,6 +23,7 @@ export default class ChannelInfoHeader extends React.PureComponent { displayName: PropTypes.string.isRequired, header: PropTypes.string, navigator: PropTypes.object.isRequired, + onPermalinkPress: PropTypes.func, purpose: PropTypes.string, status: PropTypes.string, theme: PropTypes.object.isRequired, @@ -37,6 +38,7 @@ export default class ChannelInfoHeader extends React.PureComponent { header, memberCount, navigator, + onPermalinkPress, purpose, status, theme, @@ -75,6 +77,7 @@ export default class ChannelInfoHeader extends React.PureComponent { /> wrapWithContextProvider(NotificationSettingsMentionsKeywords), store, Provider); Navigation.registerComponent('NotificationSettingsMobile', () => wrapWithContextProvider(NotificationSettingsMobile), store, Provider); Navigation.registerComponent('OptionsModal', () => wrapWithContextProvider(OptionsModal), store, Provider); + Navigation.registerComponent('Permalink', () => wrapWithContextProvider(Permalink), store, Provider); Navigation.registerComponent('Root', () => Root, store, Provider); Navigation.registerComponent('Search', () => wrapWithContextProvider(Search), store, Provider); Navigation.registerComponent('SelectServer', () => wrapWithContextProvider(SelectServer), store, Provider); diff --git a/app/screens/permalink/index.js b/app/screens/permalink/index.js new file mode 100644 index 000000000..6c7035c3b --- /dev/null +++ b/app/screens/permalink/index.js @@ -0,0 +1,76 @@ +// 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 {getChannel as getChannelAction, joinChannel, markChannelAsRead, markChannelAsViewed} from 'mattermost-redux/actions/channels'; +import {getPostsAfter, getPostsBefore, getPostThread, selectPost} from 'mattermost-redux/actions/posts'; +import {getMyChannelMemberships, makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; +import {makeGetPostIdsAroundPost, getPost} from 'mattermost-redux/selectors/entities/posts'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +import { + handleSelectChannel, + loadThreadIfNecessary, + setChannelDisplayName, + setChannelLoading, +} from 'app/actions/views/channel'; +import {handleTeamChange} from 'app/actions/views/select_team'; + +import Permalink from './permalink'; + +function makeMapStateToProps() { + const getPostIdsAroundPost = makeGetPostIdsAroundPost(); + const getChannel = makeGetChannel(); + + return function mapStateToProps(state) { + const {currentFocusedPostId} = state.entities.posts; + const post = getPost(state, currentFocusedPostId); + const channel = post ? getChannel(state, {id: post.channel_id}) : null; + let postIds; + + if (channel && channel.id) { + postIds = getPostIdsAroundPost(state, currentFocusedPostId, channel.id, { + postsBeforeCount: 10, + postsAfterCount: 10, + }); + } + + return { + channelId: channel ? channel.id : '', + channelName: channel ? channel.display_name : '', + channelTeamId: channel ? channel.team_id : '', + currentTeamId: getCurrentTeamId(state), + currentUserId: getCurrentUserId(state), + focusedPostId: currentFocusedPostId, + myMembers: getMyChannelMemberships(state), + postIds, + theme: getTheme(state), + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getPostsAfter, + getPostsBefore, + getPostThread, + getChannel: getChannelAction, + handleSelectChannel, + handleTeamChange, + joinChannel, + loadThreadIfNecessary, + markChannelAsRead, + markChannelAsViewed, + selectPost, + setChannelDisplayName, + setChannelLoading, + }, dispatch), + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps, null, {withRef: true})(Permalink); diff --git a/app/screens/permalink/permalink.js b/app/screens/permalink/permalink.js new file mode 100644 index 000000000..ef22ed1fd --- /dev/null +++ b/app/screens/permalink/permalink.js @@ -0,0 +1,455 @@ +// 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 { + InteractionManager, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import {intlShape} from 'react-intl'; +import * as Animatable from 'react-native-animatable'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; + +import {General} from 'mattermost-redux/constants'; + +import FormattedText from 'app/components/formatted_text'; +import Loading from 'app/components/loading'; +import PostList from 'app/components/post_list'; +import PostListRetry from 'app/components/post_list_retry'; +import SafeAreaView from 'app/components/safe_area_view'; +import {preventDoubleTap} from 'app/utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +Animatable.initializeRegistryWithDefinitions({ + growOut: { + from: { + opacity: 1, + scale: 1, + }, + 0.5: { + opacity: 1, + scale: 3, + }, + to: { + opacity: 0, + scale: 5, + }, + }, +}); + +export default class Permalink extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + getPostsAfter: PropTypes.func.isRequired, + getPostsBefore: PropTypes.func.isRequired, + getPostThread: PropTypes.func.isRequired, + getChannel: PropTypes.func.isRequired, + handleSelectChannel: PropTypes.func.isRequired, + handleTeamChange: PropTypes.func.isRequired, + joinChannel: PropTypes.func.isRequired, + loadThreadIfNecessary: PropTypes.func.isRequired, + markChannelAsRead: PropTypes.func.isRequired, + markChannelAsViewed: PropTypes.func.isRequired, + selectPost: PropTypes.func.isRequired, + setChannelDisplayName: PropTypes.func.isRequired, + setChannelLoading: PropTypes.func.isRequired, + }).isRequired, + channelId: PropTypes.string, + channelName: PropTypes.string, + channelTeamId: PropTypes.string, + currentTeamId: PropTypes.string.isRequired, + currentUserId: PropTypes.string.isRequired, + focusedPostId: PropTypes.string.isRequired, + isPermalink: PropTypes.bool, + myMembers: PropTypes.object.isRequired, + navigator: PropTypes.object, + onClose: PropTypes.func, + onPermalinkPress: PropTypes.func, + onPress: PropTypes.func, + postIds: PropTypes.array, + theme: PropTypes.object.isRequired, + }; + + static defaultProps = { + onPress: () => true, + postIds: [], + }; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props) { + super(props); + + const {postIds, channelName} = props; + let loading = false; + + if (postIds && postIds.length >= 10) { + loading = true; + } + + this.state = { + title: channelName, + loading, + error: '', + retry: false, + }; + } + + componentWillMount() { + if (!this.state.loading) { + this.loadPosts(this.props); + } + } + + componentWillReceiveProps(nextProps) { + if (this.props.channelName !== nextProps.channelName) { + this.setState({title: nextProps.channelName}); + } + + if (this.props.focusedPostId !== nextProps.focusedPostId) { + this.setState({loading: false}); + if (nextProps.postIds && nextProps.postIds.length < 10) { + this.loadPosts(nextProps); + } else { + this.setState({loading: true}); + } + } + } + + goToThread = preventDoubleTap((post) => { + const {actions, navigator, theme} = this.props; + const channelId = post.channel_id; + const rootId = (post.root_id || post.id); + + actions.loadThreadIfNecessary(rootId, channelId); + actions.selectPost(rootId); + + const options = { + screen: 'Thread', + animated: true, + backButtonTitle: '', + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + passProps: { + channelId, + rootId, + }, + }; + + navigator.push(options); + }); + + handleClose = () => { + const {actions, navigator, onClose} = this.props; + if (this.refs.view) { + this.refs.view.zoomOut().then(() => { + actions.selectPost(''); + navigator.dismissModal({animationType: 'none'}); + + if (onClose) { + onClose(); + } + }); + } + }; + + jumpToChannel = (channelId, channelDisplayName) => { + if (channelId) { + const {actions, channelTeamId, currentTeamId, navigator, onClose, theme} = this.props; + const currentChannelId = this.props.channelId; + const { + handleSelectChannel, + handleTeamChange, + markChannelAsRead, + setChannelLoading, + setChannelDisplayName, + markChannelAsViewed, + } = actions; + + actions.selectPost(''); + + if (onClose) { + onClose(); + } + + navigator.resetTo({ + screen: 'Channel', + animated: true, + animationType: 'fade', + navigatorStyle: { + navBarHidden: true, + statusBarHidden: false, + statusBarHideWithNavBar: false, + screenBackgroundColor: theme.centerChannelBg, + }, + }); + + if (channelTeamId && currentTeamId !== channelTeamId) { + handleTeamChange(channelTeamId, false); + } + + setChannelLoading(channelId !== currentChannelId); + setChannelDisplayName(channelDisplayName); + handleSelectChannel(channelId); + + InteractionManager.runAfterInteractions(async () => { + markChannelAsRead(channelId, currentChannelId); + if (channelId !== currentChannelId) { + markChannelAsViewed(currentChannelId); + } + }); + } + }; + + handlePress = () => { + const {channelId, channelName} = this.props; + + if (this.refs.view) { + this.refs.view.growOut().then(() => { + this.jumpToChannel(channelId, channelName); + }); + } + }; + + loadPosts = async (props) => { + const {intl} = this.context; + const {actions, channelId, currentUserId, focusedPostId, isPermalink, postIds} = props; + const {formatMessage} = intl; + let focusChannelId = channelId; + + const post = await actions.getPostThread(focusedPostId, false); + if (post.error && (!postIds || !postIds.length)) { + if (isPermalink && post.error.message.toLowerCase() !== 'network request failed') { + this.setState({ + error: formatMessage({ + id: 'permalink.error.access', + defaultMessage: 'Permalink belongs to a deleted message or to a channel to which you do not have access.', + }), + title: formatMessage({ + id: 'mobile.search.no_results', + defaultMessage: 'No Results Found', + }), + }); + } else { + this.setState({error: post.error.message, retry: true}); + } + + return; + } + + if (!channelId) { + focusChannelId = post.data.posts[focusedPostId].channel_id; + if (!this.props.myMembers[focusChannelId]) { + const {data: channel} = await actions.getChannel(focusChannelId); + if (channel && channel.type === General.OPEN_CHANNEL) { + await actions.joinChannel(currentUserId, channel.team_id, channel.id); + } + } + } + + await Promise.all([ + actions.getPostsBefore(focusChannelId, focusedPostId, 0, 10), + actions.getPostsAfter(focusChannelId, focusedPostId, 0, 10), + ]); + + this.setState({loading: true}); + }; + + retry = () => { + this.setState({loading: false, error: null, retry: false}); + this.loadPosts(this.props); + }; + + render() { + const { + currentUserId, + focusedPostId, + navigator, + onPermalinkPress, + postIds, + theme, + } = this.props; + const {error, retry, loading, title} = this.state; + const style = getStyleSheet(theme); + + let postList; + if (retry) { + postList = ( + + ); + } else if (error) { + postList = ( + + + {error} + + + ); + } else if (loading) { + postList = ( + + ); + } else { + postList = ; + } + + return ( + + + + + + + + + + {title} + + + + + {postList} + + {!error && loading && + + + + } + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + marginTop: 20, + }, + wrapper: { + flex: 1, + margin: 10, + opacity: 0, + }, + header: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2), + borderBottomWidth: 1, + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + flexDirection: 'row', + height: 44, + paddingRight: 16, + width: '100%', + }, + close: { + justifyContent: 'center', + height: 44, + width: 40, + paddingLeft: 7, + }, + titleContainer: { + alignItems: 'center', + flex: 1, + paddingRight: 40, + }, + title: { + color: theme.centerChannelColor, + fontSize: 17, + fontWeight: '600', + }, + postList: { + backgroundColor: theme.centerChannelBg, + flex: 1, + }, + bottom: { + borderBottomLeftRadius: 6, + borderBottomRightRadius: 6, + }, + footer: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.buttonBg, + flexDirection: 'row', + height: 43, + paddingRight: 16, + width: '100%', + }, + jump: { + color: theme.buttonColor, + fontSize: 15, + fontWeight: '600', + textAlignVertical: 'center', + }, + errorContainer: { + alignItems: 'center', + justifyContent: 'center', + padding: 15, + }, + errorText: { + color: changeOpacity(theme.centerChannelColor, 0.4), + fontSize: 15, + }, + }; +}); diff --git a/app/screens/search/index.js b/app/screens/search/index.js index 66d14445b..d96ea926c 100644 --- a/app/screens/search/index.js +++ b/app/screens/search/index.js @@ -4,19 +4,13 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {markChannelAsRead, markChannelAsViewed} from 'mattermost-redux/actions/channels'; -import {selectPost} from 'mattermost-redux/actions/posts'; +import {selectFocusedPostId, selectPost} from 'mattermost-redux/actions/posts'; import {clearSearch, removeSearchTerms, searchPosts} from 'mattermost-redux/actions/search'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import { - handleSelectChannel, - loadThreadIfNecessary, - setChannelDisplayName, - setChannelLoading, -} from 'app/actions/views/channel'; +import {loadChannelsByTeamName, loadThreadIfNecessary} from 'app/actions/views/channel'; import {isLandscape} from 'app/selectors/device'; import {handleSearchDraftChanged} from 'app/actions/views/search'; @@ -44,15 +38,12 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ clearSearch, handleSearchDraftChanged, - handleSelectChannel, + loadChannelsByTeamName, loadThreadIfNecessary, - markChannelAsRead, - markChannelAsViewed, removeSearchTerms, + selectFocusedPostId, searchPosts, selectPost, - setChannelDisplayName, - setChannelLoading, }, dispatch), }; } diff --git a/app/screens/search/search.js b/app/screens/search/search.js index 4c55a435d..858cc7200 100644 --- a/app/screens/search/search.js +++ b/app/screens/search/search.js @@ -3,10 +3,9 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {injectIntl, intlShape} from 'react-intl'; +import {intlShape} from 'react-intl'; import { Keyboard, - InteractionManager, Platform, SectionList, Text, @@ -26,7 +25,6 @@ import Loading from 'app/components/loading'; import PostListRetry from 'app/components/post_list_retry'; import SafeAreaView from 'app/components/safe_area_view'; import SearchBar from 'app/components/search_bar'; -import SearchPreview from 'app/components/search_preview'; import StatusBar from 'app/components/status_bar'; import mattermostManaged from 'app/mattermost_managed'; import {preventDoubleTap} from 'app/utils/tap'; @@ -42,21 +40,20 @@ const MODIFIER_LABEL_HEIGHT = 58; const SEARCHING = 'searching'; const NO_RESULTS = 'no results'; -class Search extends PureComponent { +export default class Search extends PureComponent { static propTypes = { actions: PropTypes.shape({ clearSearch: PropTypes.func.isRequired, handleSearchDraftChanged: PropTypes.func.isRequired, + loadChannelsByTeamName: PropTypes.func.isRequired, loadThreadIfNecessary: PropTypes.func.isRequired, - markChannelAsRead: PropTypes.func.isRequired, - markChannelAsViewed: PropTypes.func.isRequired, removeSearchTerms: PropTypes.func.isRequired, searchPosts: PropTypes.func.isRequired, + selectFocusedPostId: PropTypes.func.isRequired, selectPost: PropTypes.func.isRequired, }).isRequired, currentTeamId: PropTypes.string.isRequired, currentChannelId: PropTypes.string.isRequired, - intl: intlShape.isRequired, isLandscape: PropTypes.bool.isRequired, navigator: PropTypes.object, postIds: PropTypes.array, @@ -70,6 +67,10 @@ class Search extends PureComponent { recent: [], }; + static contextTypes = { + intl: intlShape.isRequired, + }; + constructor(props) { super(props); @@ -77,9 +78,6 @@ class Search extends PureComponent { this.isX = DeviceInfo.getModel() === 'iPhone X'; this.state = { channelName: '', - focusedChannelId: null, - focusedPostId: null, - preview: false, value: '', managedConfig: {}, }; @@ -160,6 +158,17 @@ class Search extends PureComponent { navigator.push(options); }; + handleClosePermalink = () => { + const {actions} = this.props; + actions.selectFocusedPostId(''); + this.showingPermalink = false; + }; + + handlePermalinkPress = (postId, teamName) => { + this.props.actions.loadChannelsByTeamName(teamName); + this.showPermalinkView(postId, true); + }; + handleSelectionChange = (event) => { if (this.autocomplete) { this.autocomplete.getWrappedInstance().handleSelectionChange(event); @@ -201,10 +210,6 @@ class Search extends PureComponent { return item.id || item; }; - onFocus = () => { - this.scrollToTop(); - }; - onNavigatorEvent = (event) => { if (event.id === 'backPress') { if (this.state.preview) { @@ -218,11 +223,34 @@ class Search extends PureComponent { previewPost = (post) => { Keyboard.dismiss(); - this.setState({ - preview: true, - focusedChannelId: post.channel_id, - focusedPostId: post.id, - }); + this.showPermalinkView(post.id, false); + }; + + showPermalinkView = (postId, isPermalink) => { + const {actions, navigator} = this.props; + + actions.selectFocusedPostId(postId); + + if (!this.showingPermalink) { + const options = { + screen: 'Permalink', + animationType: 'none', + backButtonTitle: '', + navigatorStyle: { + navBarHidden: true, + screenBackgroundColor: changeOpacity('#000', 0.2), + modalPresentationStyle: 'overCurrentContext', + }, + passProps: { + isPermalink, + onClose: this.handleClosePermalink, + onPermalinkPress: this.handlePermalinkPress, + }, + }; + + this.showingPermalink = true; + navigator.showModal(options); + } }; removeSearchTerms = preventDoubleTap((item) => { @@ -290,6 +318,7 @@ class Search extends PureComponent { previewPost={this.previewPost} goToThread={this.goToThread} navigator={this.props.navigator} + onPermalinkPress={this.handlePermalinkPress} managedConfig={managedConfig} /> {separator} @@ -440,60 +469,20 @@ class Search extends PureComponent { const {terms, isOrSearch} = recent; this.handleTextChanged(terms); this.search(terms, isOrSearch); + Keyboard.dismiss(); }); - handleClosePreview = () => { - this.setState({ - preview: false, - focusedChannelId: null, - focusedPostId: null, - }); - }; - - handleJumpToChannel = (channelId, channelDisplayName) => { - if (channelId) { - const {actions, currentChannelId} = this.props; - const { - handleSelectChannel, - markChannelAsRead, - setChannelLoading, - setChannelDisplayName, - markChannelAsViewed, - } = actions; - - setChannelLoading(channelId !== currentChannelId); - setChannelDisplayName(channelDisplayName); - - InteractionManager.runAfterInteractions(() => { - handleSelectChannel(channelId); - requestAnimationFrame(() => { - // mark the channel as viewed after all the frame has flushed - markChannelAsRead(channelId, currentChannelId); - if (channelId !== currentChannelId) { - markChannelAsViewed(currentChannelId); - } - }); - - this.props.navigator.dismissModal({animationType: 'slide-down'}); - }); - } - }; - render() { const { - intl, isLandscape, - navigator, postIds, recent, searchingStatus, theme, } = this.props; - const { - preview, - value, - } = this.state; + const {intl} = this.context; + const {value} = this.state; const style = getStyleFromTheme(theme); const sections = [{ data: [{ @@ -522,7 +511,7 @@ class Search extends PureComponent { sections.push({ data: recent, key: 'recent', - title: intl.formatMessage({id: 'mobile.search.recentTitle', defaultMessage: 'Recent Searches'}), + title: intl.formatMessage({id: 'mobile.search.recent_title', defaultMessage: 'Recent Searches'}), renderItem: this.renderRecentItem, keyExtractor: this.keyRecentExtractor, ItemSeparatorComponent: this.renderRecentSeparator, @@ -583,23 +572,6 @@ class Search extends PureComponent { }); } - let previewComponent; - if (preview) { - const {focusedChannelId, focusedPostId} = this.state; - - previewComponent = ( - - ); - } - const searchBarInput = { backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.2), color: theme.sidebarHeaderTextColor, @@ -651,7 +623,6 @@ class Search extends PureComponent { isSearch={true} value={value} /> - {previewComponent} ); @@ -778,4 +749,3 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { }; }); -export default injectIntl(Search); diff --git a/app/screens/search/search_result_post/search_result_post.js b/app/screens/search/search_result_post/search_result_post.js index 50ecfad23..d934fcd87 100644 --- a/app/screens/search/search_result_post/search_result_post.js +++ b/app/screens/search/search_result_post/search_result_post.js @@ -12,6 +12,7 @@ export default class SearchResultPost extends PureComponent { goToThread: PropTypes.func.isRequired, managedConfig: PropTypes.object.isRequired, navigator: PropTypes.object.isRequired, + onPermalinkPress: PropTypes.func.isRequired, postId: PropTypes.string.isRequired, previewPost: PropTypes.func.isRequired, }; @@ -26,6 +27,7 @@ export default class SearchResultPost extends PureComponent { postComponentProps.onReply = this.props.goToThread; postComponentProps.shouldRenderReplyButton = true; postComponentProps.managedConfig = this.props.managedConfig; + postComponentProps.onPermalinkPress = this.props.onPermalinkPress; } return ( diff --git a/app/screens/user_profile/user_profile.js b/app/screens/user_profile/user_profile.js index 10dc20271..647e9cdb5 100644 --- a/app/screens/user_profile/user_profile.js +++ b/app/screens/user_profile/user_profile.js @@ -4,7 +4,6 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { - Platform, ScrollView, Text, View, @@ -47,17 +46,20 @@ class UserProfile extends PureComponent { } close = () => { - const {navigator} = this.props; + const {navigator, theme} = this.props; - navigator.popToRoot({ + navigator.resetTo({ + screen: 'Channel', animated: true, + navigatorStyle: { + animated: true, + animationType: 'fade', + navBarHidden: true, + statusBarHidden: false, + statusBarHideWithNavBar: false, + screenBackgroundColor: theme.centerChannelBg, + }, }); - - if (Platform.OS === 'android') { - navigator.dismissModal({ - animationType: 'slide-down', - }); - } }; displaySendMessageOption = () => { diff --git a/app/utils/markdown.js b/app/utils/markdown.js index 5425e18c7..f1c8c2ae8 100644 --- a/app/utils/markdown.js +++ b/app/utils/markdown.js @@ -181,3 +181,7 @@ const languages = { export function getDisplayNameForLanguage(language) { return languages[language.toLowerCase()] || ''; } + +export function escapeRegex(text) { + return text.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 141817748..a26207040 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -2166,8 +2166,9 @@ "mobile.search.from_modifier_title": "username", "mobile.search.in_modifier_description": "to find posts in specific channels", "mobile.search.in_modifier_title": "channel-name", - "mobile.search.jump": "JUMP", + "mobile.search.jump": "Jump to recent messages", "mobile.search.no_results": "No Results Found", + "mobile.search.recent_title": "Recent Searches", "mobile.select_team.choose": "Your teams:", "mobile.select_team.join_open": "Open teams you can join", "mobile.select_team.no_teams": "There are no available teams for you to join.",