MM-10273 Add hashtag support (#2106)

* MM-10273 Add hashtag support

* Put hashtag search in a proper modal

* Change hashtag search style to match regular search

* Move Hashtag component and explicitly pass props

* Add unit tests for Hashtag component

* Fix merge conflict
This commit is contained in:
Harrison Healey 2018-09-17 16:17:20 -04:00 committed by Elias Nahum
parent 07f88071c6
commit 578f204784
14 changed files with 291 additions and 15 deletions

View file

@ -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__): <Hashtag
hashtag="test"
linkStyle={
Object {
"color": "red",
}
}
navigator={Object {}}
theme={Object {}}
/>,
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,
},
},
},
}
`;

View file

@ -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 (
<Text
style={this.props.linkStyle}
onPress={this.handlePress}
>
{`#${this.props.hashtag}`}
</Text>
);
}
}

View file

@ -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(<Hashtag {...baseProps}/>);
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(<Hashtag {...props}/>);
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(<Hashtag {...props}/>);
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();
});
});

View file

@ -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 (
<Hashtag
hashtag={hashtag}
linkStyle={this.props.textStyles.link}
onHashtagPress={this.props.onHashtagPress}
navigator={this.props.navigator}
theme={this.props.theme}
/>
);
}
renderParagraph = ({children, first}) => {
if (!children || children.length === 0) {
return null;

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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}
/>

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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}
/>

View file

@ -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;
}

4
package-lock.json generated
View file

@ -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",