diff --git a/app/components/markdown/__snapshots__/hashtag.test.js.snap b/app/components/markdown/__snapshots__/hashtag.test.js.snap new file mode 100644 index 000000000..30476ed77 --- /dev/null +++ b/app/components/markdown/__snapshots__/hashtag.test.js.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Hashtag should match snapshot 1`] = ` +ShallowWrapper { + "length": 1, + Symbol(enzyme.__root__): [Circular], + Symbol(enzyme.__unrendered__): , + Symbol(enzyme.__renderer__): Object { + "batchedUpdates": [Function], + "getNode": [Function], + "render": [Function], + "simulateEvent": [Function], + "unmount": [Function], + }, + Symbol(enzyme.__node__): Object { + "instance": null, + "key": undefined, + "nodeType": "class", + "props": Object { + "accessible": true, + "allowFontScaling": true, + "children": "#test", + "ellipsizeMode": "tail", + "onPress": [Function], + "style": Object { + "color": "red", + }, + }, + "ref": null, + "rendered": "#test", + "type": [Function], + }, + Symbol(enzyme.__nodes__): Array [ + Object { + "instance": null, + "key": undefined, + "nodeType": "class", + "props": Object { + "accessible": true, + "allowFontScaling": true, + "children": "#test", + "ellipsizeMode": "tail", + "onPress": [Function], + "style": Object { + "color": "red", + }, + }, + "ref": null, + "rendered": "#test", + "type": [Function], + }, + ], + Symbol(enzyme.__options__): Object { + "adapter": ReactSixteenAdapter { + "options": Object { + "enableComponentDidUpdateOnSetState": true, + }, + }, + }, +} +`; diff --git a/app/components/markdown/hashtag.js b/app/components/markdown/hashtag.js new file mode 100644 index 000000000..424f58420 --- /dev/null +++ b/app/components/markdown/hashtag.js @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {intlShape} from 'react-intl'; +import {Text} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; + +export default class Hashtag extends React.PureComponent { + static propTypes = { + hashtag: PropTypes.string.isRequired, + linkStyle: CustomPropTypes.Style.isRequired, + onHashtagPress: PropTypes.func, + navigator: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + handlePress = () => { + if (this.props.onHashtagPress) { + this.props.onHashtagPress(this.props.hashtag); + return; + } + + const options = { + screen: 'Search', + animated: true, + backButtonTitle: '', + passProps: { + initialValue: '#' + this.props.hashtag, + }, + navigatorStyle: { + navBarHidden: true, + screenBackgroundColor: this.props.theme.centerChannelBg, + }, + }; + + // Close thread view, permalink view, etc + this.props.navigator.dismissAllModals(); + this.props.navigator.popToRoot(); + + this.props.navigator.showModal(options); + }; + + render() { + return ( + + {`#${this.props.hashtag}`} + + ); + } +} diff --git a/app/components/markdown/hashtag.test.js b/app/components/markdown/hashtag.test.js new file mode 100644 index 000000000..4d3128891 --- /dev/null +++ b/app/components/markdown/hashtag.test.js @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; +import {Text} from 'react-native'; + +import Hashtag from './hashtag'; + +describe('Hashtag', () => { + const baseProps = { + hashtag: 'test', + linkStyle: {color: 'red'}, + navigator: {}, + theme: {}, + }; + + test('should match snapshot', () => { + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should open hashtag search on click', () => { + const props = { + ...baseProps, + navigator: { + dismissAllModals: jest.fn(), + popToRoot: jest.fn(), + showModal: jest.fn(), + }, + }; + + const wrapper = shallow(); + + wrapper.find(Text).simulate('press'); + + expect(props.navigator.dismissAllModals).toHaveBeenCalled(); + expect(props.navigator.popToRoot).toHaveBeenCalled(); + expect(props.navigator.showModal).toHaveBeenCalledWith(expect.objectContaining({ + screen: 'Search', + passProps: { + initialValue: '#test', + }, + })); + }); + + test('should call onHashtagPress if provided', () => { + const props = { + ...baseProps, + navigator: { + dismissAllModals: jest.fn(), + popToRoot: jest.fn(), + showModal: jest.fn(), + }, + onHashtagPress: jest.fn(), + }; + + const wrapper = shallow(); + + wrapper.find(Text).simulate('press'); + + expect(props.navigator.dismissAllModals).not.toBeCalled(); + expect(props.navigator.popToRoot).not.toBeCalled(); + expect(props.navigator.showModal).not.toBeCalled(); + + expect(props.onHashtagPress).toBeCalled(); + }); +}); diff --git a/app/components/markdown/markdown.js b/app/components/markdown/markdown.js index c638a79e6..93d7dab43 100644 --- a/app/components/markdown/markdown.js +++ b/app/components/markdown/markdown.js @@ -15,6 +15,7 @@ import AtMention from 'app/components/at_mention'; import ChannelLink from 'app/components/channel_link'; import Emoji from 'app/components/emoji'; import FormattedText from 'app/components/formatted_text'; +import Hashtag from 'app/components/hashtag'; import CustomPropTypes from 'app/constants/custom_prop_types'; import {blendColors, concatStyles, makeStyleSheetFromTheme} from 'app/utils/theme'; import {getScheme} from 'app/utils/url'; @@ -41,6 +42,7 @@ export default class Markdown extends PureComponent { isSearchResult: PropTypes.bool, navigator: PropTypes.object.isRequired, onChannelLinkPress: PropTypes.func, + onHashtagPress: PropTypes.func, onLongPress: PropTypes.func, onPermalinkPress: PropTypes.func, onPostPress: PropTypes.func, @@ -88,6 +90,7 @@ export default class Markdown extends PureComponent { atMention: this.renderAtMention, channelLink: this.renderChannelLink, emoji: this.renderEmoji, + hashtag: this.renderHashtag, paragraph: this.renderParagraph, heading: this.renderHeading, @@ -213,6 +216,18 @@ export default class Markdown extends PureComponent { ); } + renderHashtag = ({hashtag}) => { + return ( + + ); + } + renderParagraph = ({children, first}) => { if (!children || children.length === 0) { return null; diff --git a/app/components/message_attachments/index.js b/app/components/message_attachments/index.js index 20cbf4eb4..857422ee4 100644 --- a/app/components/message_attachments/index.js +++ b/app/components/message_attachments/index.js @@ -16,6 +16,7 @@ export default class MessageAttachments extends PureComponent { blockStyles: PropTypes.object, postId: PropTypes.string.isRequired, navigator: PropTypes.object.isRequired, + onHashtagPress: PropTypes.func, onLongPress: PropTypes.func.isRequired, onPermalinkPress: PropTypes.func, theme: PropTypes.object, @@ -28,6 +29,7 @@ export default class MessageAttachments extends PureComponent { baseTextStyle, blockStyles, navigator, + onHashtagPress, onLongPress, onPermalinkPress, postId, @@ -44,6 +46,7 @@ export default class MessageAttachments extends PureComponent { blockStyles={blockStyles} key={'att_' + i} navigator={navigator} + onHashtagPress={onHashtagPress} onLongPress={onLongPress} onPermalinkPress={onPermalinkPress} postId={postId} diff --git a/app/components/post/post.js b/app/components/post/post.js index 185f911ab..b51f0c15f 100644 --- a/app/components/post/post.js +++ b/app/components/post/post.js @@ -58,6 +58,7 @@ export default class Post extends PureComponent { canEdit: PropTypes.bool.isRequired, canEditUntil: PropTypes.number.isRequired, canDelete: PropTypes.bool.isRequired, + onHashtagPress: PropTypes.func, onPermalinkPress: PropTypes.func, shouldRenderReplyButton: PropTypes.bool, showAddReaction: PropTypes.bool, @@ -349,6 +350,7 @@ export default class Post extends PureComponent { highlight, isLastReply, isSearchResult, + onHashtagPress, onPermalinkPress, post, renderReplies, @@ -448,6 +450,7 @@ export default class Post extends PureComponent { onCopyPermalink={this.handleCopyPermalink} onCopyText={this.handleCopyText} onFailedPostPress={this.handleFailedPostPress} + onHashtagPress={onHashtagPress} onPermalinkPress={onPermalinkPress} onPostDelete={this.handlePostDelete} onPostEdit={this.handlePostEdit} diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index 59a9effbe..f309681d6 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -62,6 +62,7 @@ export default class PostBody extends PureComponent { onCopyPermalink: PropTypes.func, onCopyText: PropTypes.func, onFailedPostPress: PropTypes.func, + onHashtagPress: PropTypes.func, onPermalinkPress: PropTypes.func, onPostDelete: PropTypes.func, onPostEdit: PropTypes.func, @@ -201,7 +202,15 @@ export default class PostBody extends PureComponent { }; openLongPost = preventDoubleTap(() => { - const {managedConfig, navigator, onAddReaction, onPermalinkPress, postId} = this.props; + const { + managedConfig, + navigator, + onAddReaction, + onHashtagPress, + onPermalinkPress, + postId, + } = this.props; + const options = { screen: 'LongPost', animationType: 'none', @@ -216,6 +225,7 @@ export default class PostBody extends PureComponent { postId, managedConfig, onAddReaction, + onHashtagPress, onPermalinkPress, }, }; @@ -235,7 +245,7 @@ export default class PostBody extends PureComponent { }; renderAddChannelMember = (style, messageStyle, textStyles) => { - const {onPermalinkPress, onPress, postProps} = this.props; + const {onPress, postProps} = this.props; if (!PostAddChannelMember) { PostAddChannelMember = require('app/components/post_add_channel_member').default; @@ -248,7 +258,6 @@ export default class PostBody extends PureComponent { baseTextStyle={messageStyle} navigator={navigator} onLongPress={this.showOptionsContext} - onPermalinkPress={onPermalinkPress} onPostPress={onPress} textStyles={textStyles} postId={postProps.add_channel_member.post_id} @@ -296,7 +305,7 @@ export default class PostBody extends PureComponent { } renderPostAdditionalContent = (blockStyles, messageStyle, textStyles) => { - const {isReplyPost, message, navigator, onPermalinkPress, postId, postProps} = this.props; + const {isReplyPost, message, navigator, onHashtagPress, onPermalinkPress, postId, postProps} = this.props; if (!PostBodyAdditionalContent) { PostBodyAdditionalContent = require('app/components/post_body_additional_content').default; @@ -311,8 +320,9 @@ export default class PostBody extends PureComponent { postId={postId} postProps={postProps} textStyles={textStyles} - onLongPress={this.showOptionsContext} isReplyPost={isReplyPost} + onHashtagPress={onHashtagPress} + onLongPress={this.showOptionsContext} onPermalinkPress={onPermalinkPress} /> ); @@ -352,6 +362,7 @@ export default class PostBody extends PureComponent { message, navigator, onFailedPostPress, + onHashtagPress, onPermalinkPress, onPress, postProps, @@ -422,6 +433,7 @@ export default class PostBody extends PureComponent { isReplyPost={isReplyPost} isSearchResult={isSearchResult} navigator={navigator} + onHashtagPress={onHashtagPress} onLongPress={this.showOptionsContext} onPermalinkPress={onPermalinkPress} onPostPress={onPress} 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 0042265d8..55aa1afe6 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 @@ -44,6 +44,7 @@ export default class PostBodyAdditionalContent extends PureComponent { link: PropTypes.string, message: PropTypes.string.isRequired, navigator: PropTypes.object.isRequired, + onHashtagPress: PropTypes.func, onLongPress: PropTypes.func, onPermalinkPress: PropTypes.func, openGraphData: PropTypes.object, @@ -291,6 +292,7 @@ export default class PostBodyAdditionalContent extends PureComponent { baseTextStyle, blockStyles, navigator, + onHashtagPress, onPermalinkPress, textStyles, theme, @@ -311,6 +313,7 @@ export default class PostBodyAdditionalContent extends PureComponent { postId={postId} textStyles={textStyles} theme={theme} + onHashtagPress={onHashtagPress} onLongPress={this.props.onLongPress} onPermalinkPress={onPermalinkPress} /> diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index c933abc5b..8803d4869 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -51,6 +51,7 @@ export default class PostList extends PureComponent { navigator: PropTypes.object, onContentSizeChange: PropTypes.func, onEndReached: PropTypes.func, + onHashtagPress: PropTypes.func, onPermalinkPress: PropTypes.func, onPostPress: PropTypes.func, onRefresh: PropTypes.func, @@ -146,7 +147,11 @@ export default class PostList extends PureComponent { }; showPermalinkView = (postId) => { - const {actions, navigator} = this.props; + const { + actions, + navigator, + onHashtagPress, + } = this.props; actions.selectFocusedPostId(postId); @@ -164,6 +169,7 @@ export default class PostList extends PureComponent { passProps: { isPermalink: true, onClose: this.handleClosePermalink, + onHashtagPress, onPermalinkPress: this.handlePermalinkPress, }, }; @@ -314,6 +320,7 @@ export default class PostList extends PureComponent { highlightPostId, isSearchResult, navigator, + onHashtagPress, onPostPress, renderReplies, shouldRenderReplyButton, @@ -335,6 +342,7 @@ export default class PostList extends PureComponent { renderReplies={renderReplies} isSearchResult={isSearchResult} shouldRenderReplyButton={shouldRenderReplyButton} + onHashtagPress={onHashtagPress} onPermalinkPress={this.handlePermalinkPress} onPress={onPostPress} navigator={navigator} diff --git a/app/screens/long_post/long_post.js b/app/screens/long_post/long_post.js index 973b25fbb..d90d477d2 100644 --- a/app/screens/long_post/long_post.js +++ b/app/screens/long_post/long_post.js @@ -52,6 +52,7 @@ export default class LongPost extends PureComponent { managedConfig: PropTypes.object, navigator: PropTypes.object, onAddReaction: PropTypes.func, + onHashtagPress: PropTypes.func, onPermalinkPress: PropTypes.func, postId: PropTypes.string.isRequired, theme: PropTypes.object.isRequired, @@ -178,6 +179,7 @@ export default class LongPost extends PureComponent { hasReactions, managedConfig, navigator, + onHashtagPress, onPermalinkPress, postId, theme, @@ -239,6 +241,7 @@ export default class LongPost extends PureComponent { onPress={this.handlePress} isSearchResult={false} showLongPost={true} + onHashtagPress={onHashtagPress} onPermalinkPress={onPermalinkPress} navigator={navigator} managedConfig={managedConfig} diff --git a/app/screens/permalink/permalink.js b/app/screens/permalink/permalink.js index c49e95c74..8badb1957 100644 --- a/app/screens/permalink/permalink.js +++ b/app/screens/permalink/permalink.js @@ -70,6 +70,7 @@ export default class Permalink extends PureComponent { myMembers: PropTypes.object.isRequired, navigator: PropTypes.object, onClose: PropTypes.func, + onHashtagPress: PropTypes.func, onPermalinkPress: PropTypes.func, onPress: PropTypes.func, postIds: PropTypes.array, @@ -319,6 +320,7 @@ export default class Permalink extends PureComponent { currentUserId, focusedPostId, navigator, + onHashtagPress, onPermalinkPress, postIds, theme, @@ -352,6 +354,7 @@ export default class Permalink extends PureComponent { isSearchResult={false} shouldRenderReplyButton={false} renderReplies={true} + onHashtagPress={onHashtagPress} onPermalinkPress={onPermalinkPress} onPostPress={this.goToThread} postIds={postIds} diff --git a/app/screens/search/search.js b/app/screens/search/search.js index 7d2534248..8a5ecabc6 100644 --- a/app/screens/search/search.js +++ b/app/screens/search/search.js @@ -57,6 +57,7 @@ export default class Search extends PureComponent { selectPost: PropTypes.func.isRequired, }).isRequired, currentTeamId: PropTypes.string.isRequired, + initialValue: PropTypes.string, isLandscape: PropTypes.bool.isRequired, navigator: PropTypes.object, postIds: PropTypes.array, @@ -69,6 +70,7 @@ export default class Search extends PureComponent { }; static defaultProps = { + initialValue: '', postIds: [], recent: [], archivedPostIds: [], @@ -86,7 +88,7 @@ export default class Search extends PureComponent { this.state = { channelName: '', cursorPosition: 0, - value: '', + value: props.initialValue, managedConfig: {}, }; } @@ -97,9 +99,14 @@ export default class Search extends PureComponent { componentDidMount() { this.setManagedConfig(); - if (this.refs.searchBar) { + + if (this.props.initialValue) { + this.search(this.props.initialValue); + } else { setTimeout(() => { - this.refs.searchBar.focus(); + if (this.refs.searchBar) { + this.refs.searchBar.focus(); + } }, 150); } } @@ -117,10 +124,12 @@ export default class Search extends PureComponent { if (shouldScroll) { setTimeout(() => { const modifiersCount = enableDateSuggestion ? 5 : 2; - this.refs.list._wrapperListRef.getListRef().scrollToOffset({ //eslint-disable-line no-underscore-dangle - animated: true, - offset: SECTION_HEIGHT + (modifiersCount * MODIFIER_LABEL_HEIGHT) + (recentLength * RECENT_LABEL_HEIGHT) + ((recentLength + 1) * RECENT_SEPARATOR_HEIGHT), - }); + if (this.refs.list) { + this.refs.list._wrapperListRef.getListRef().scrollToOffset({ //eslint-disable-line no-underscore-dangle + animated: true, + offset: SECTION_HEIGHT + (modifiersCount * MODIFIER_LABEL_HEIGHT) + (recentLength * RECENT_LABEL_HEIGHT) + ((recentLength + 1) * RECENT_SEPARATOR_HEIGHT), + }); + } }, 100); } } @@ -163,6 +172,20 @@ export default class Search extends PureComponent { navigator.push(options); }; + handleHashtagPress = (hashtag) => { + if (this.showingPermalink) { + this.props.navigator.dismissModal(); + this.handleClosePermalink(); + } + + const terms = '#' + hashtag; + + this.handleTextChanged(terms); + this.search(terms, false); + + Keyboard.dismiss(); + }; + handleClosePermalink = () => { const {actions} = this.props; actions.selectFocusedPostId(''); @@ -251,6 +274,7 @@ export default class Search extends PureComponent { passProps: { isPermalink, onClose: this.handleClosePermalink, + onHashtagPress: this.handleHashtagPress, onPermalinkPress: this.handlePermalinkPress, }, }; @@ -360,6 +384,7 @@ export default class Search extends PureComponent { previewPost={this.previewPost} goToThread={this.goToThread} navigator={this.props.navigator} + onHashtagPress={this.handleHashtagPress} onPermalinkPress={this.handlePermalinkPress} managedConfig={managedConfig} /> 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 c1a953855..be62a3132 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, + onHashtagPress: PropTypes.func.isRequired, onPermalinkPress: PropTypes.func.isRequired, postId: PropTypes.string.isRequired, previewPost: PropTypes.func.isRequired, @@ -32,6 +33,7 @@ export default class SearchResultPost extends PureComponent { postComponentProps.onReply = this.props.goToThread; postComponentProps.shouldRenderReplyButton = true; postComponentProps.managedConfig = this.props.managedConfig; + postComponentProps.onHashtagPress = this.props.onHashtagPress; postComponentProps.onPermalinkPress = this.props.onPermalinkPress; } diff --git a/package-lock.json b/package-lock.json index 3da995544..537ba26eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5325,8 +5325,8 @@ } }, "commonmark-react-renderer": { - "version": "github:mattermost/commonmark-react-renderer#86fa63f898802953842526c2030f3b63c5d1ae7a", - "from": "commonmark-react-renderer@github:mattermost/commonmark-react-renderer#86fa63f898802953842526c2030f3b63c5d1ae7a", + "version": "github:mattermost/commonmark-react-renderer#b560513b93357ee5fd33489fe311bc65d4658870", + "from": "github:mattermost/commonmark-react-renderer#b560513b93357ee5fd33489fe311bc65d4658870", "requires": { "in-publish": "^2.0.0", "lodash.assign": "^4.2.0",