From dc8b9a04b125af28fdb394f17753108957efa457 Mon Sep 17 00:00:00 2001 From: enahum Date: Fri, 21 Jul 2017 17:07:47 -0400 Subject: [PATCH] RN-10 Ability to search Posts (#763) * Fix offline indicator UI * Add search screen with recent mentions functionality * Fix make start * Add autocomplete to the search box * Fix search bar in other screens * Get search results and scroll the list * Add reply arrow to search result posts * Search result preview and jump to channel * Feedback review --- Makefile | 4 +- app/actions/views/channel.js | 13 +- app/actions/views/search.js | 13 + app/components/at_mention/at_mention.js | 7 +- .../autocomplete/at_mention/at_mention.js | 76 ++- .../autocomplete/at_mention/index.js | 10 +- .../channel_mention/channel_mention.js | 38 +- .../autocomplete/channel_mention/index.js | 4 +- app/components/autocomplete/index.js | 25 +- app/components/markdown/index.js | 4 + .../offline_indicator/offline_indicator.js | 7 +- app/components/post/index.js | 1 + app/components/post/post.js | 39 +- app/components/post_body/post_body.js | 68 ++- app/components/post_header/post_header.js | 9 +- app/components/post_list/post_list.js | 18 +- .../scrollable_section_list/index.js | 30 + .../virtualized_section_list.js | 26 + app/components/search_preview/index.js | 35 ++ .../search_preview/search_preview.js | 234 +++++++ app/constants/view.js | 1 + app/mattermost.js | 6 + app/reducers/views/index.js | 2 + app/reducers/views/search.js | 18 + app/screens/channel/channel.js | 2 + app/screens/channel/channel_drawer_button.js | 1 + .../channel_post_list/channel_post_list.js | 2 + .../channel/channel_post_list/index.js | 3 +- app/screens/channel/channel_search_button.js | 116 ++++ app/screens/channel/channel_title.js | 2 +- app/screens/index.js | 2 + app/screens/search/index.js | 63 ++ app/screens/search/search.js | 574 ++++++++++++++++++ assets/base/i18n/en.json | 2 + package.json | 1 + yarn.lock | 6 +- 36 files changed, 1382 insertions(+), 80 deletions(-) create mode 100644 app/actions/views/search.js create mode 100644 app/components/scrollable_section_list/index.js create mode 100644 app/components/scrollable_section_list/virtualized_section_list.js create mode 100644 app/components/search_preview/index.js create mode 100644 app/components/search_preview/search_preview.js create mode 100644 app/reducers/views/search.js create mode 100644 app/screens/channel/channel_search_button.js create mode 100644 app/screens/search/index.js create mode 100644 app/screens/search/search.js diff --git a/Makefile b/Makefile index 197343ff6..80fe255f9 100644 --- a/Makefile +++ b/Makefile @@ -105,12 +105,12 @@ post-install: sed -i'' -e 's|"./locale-data/complete.js": false|"./locale-data/complete.js": "./locale-data/complete.js"|g' node_modules/intl/package.json start-packager: - @if [ $(shell ps -a | grep "cli.js start" | grep -civ grep) -eq 0 ]; then \ + @if [ $(shell ps -e | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \ echo Starting React Native packager server; \ node ./node_modules/react-native/local-cli/cli.js start --reset-cache & echo $$! > server.PID; \ else \ echo React Native packager server already running; \ - ps -a | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \ + ps -e | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \ fi stop-packager: diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index e03093e23..64e92c04c 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -12,7 +12,7 @@ import { selectChannel, leaveChannel as serviceLeaveChannel } from 'mattermost-redux/actions/channels'; -import {getPosts, getPostsBefore, getPostsSince} from 'mattermost-redux/actions/posts'; +import {getPosts, getPostsBefore, getPostsSince, getPostThread} from 'mattermost-redux/actions/posts'; import {getFilesForPost} from 'mattermost-redux/actions/files'; import {savePreferences, deletePreferences} from 'mattermost-redux/actions/preferences'; import {getTeamMembersByIds} from 'mattermost-redux/actions/teams'; @@ -166,6 +166,17 @@ export function loadFilesForPostIfNecessary(postId) { }; } +export function loadThreadIfNecessary(rootId) { + return async (dispatch, getState) => { + const state = getState(); + const {posts} = state.entities.posts; + + if (rootId && !posts[rootId]) { + getPostThread(rootId)(dispatch, getState); + } + }; +} + export function selectInitialChannel(teamId) { return async (dispatch, getState) => { const state = getState(); diff --git a/app/actions/views/search.js b/app/actions/views/search.js new file mode 100644 index 000000000..e32b9f63c --- /dev/null +++ b/app/actions/views/search.js @@ -0,0 +1,13 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ViewTypes} from 'app/constants'; + +export function handleSearchDraftChanged(text) { + return async (dispatch, getState) => { + dispatch({ + type: ViewTypes.SEARCH_DRAFT_CHANGED, + text + }, getState); + }; +} diff --git a/app/components/at_mention/at_mention.js b/app/components/at_mention/at_mention.js index f0d7ec094..72f961734 100644 --- a/app/components/at_mention/at_mention.js +++ b/app/components/at_mention/at_mention.js @@ -11,9 +11,11 @@ import CustomPropTypes from 'app/constants/custom_prop_types'; class AtMention extends React.PureComponent { static propTypes = { intl: intlShape, + isSearchResult: PropTypes.bool, mentionName: PropTypes.string.isRequired, mentionStyle: CustomPropTypes.Style, navigator: PropTypes.object.isRequired, + onPostPress: PropTypes.func, textStyle: CustomPropTypes.Style, theme: PropTypes.object.isRequired, usersByUsername: PropTypes.object.isRequired @@ -41,6 +43,7 @@ class AtMention extends React.PureComponent { goToUserProfile = () => { const {intl, navigator, theme} = this.props; + navigator.push({ screen: 'UserProfile', title: intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}), @@ -84,7 +87,7 @@ class AtMention extends React.PureComponent { } render() { - const {mentionName, mentionStyle, textStyle} = this.props; + const {isSearchResult, mentionName, mentionStyle, onPostPress, textStyle} = this.props; const username = this.state.username; if (!username) { @@ -96,7 +99,7 @@ class AtMention extends React.PureComponent { return ( {'@' + username} diff --git a/app/components/autocomplete/at_mention/at_mention.js b/app/components/autocomplete/at_mention/at_mention.js index 43e5f9cb5..720fa5824 100644 --- a/app/components/autocomplete/at_mention/at_mention.js +++ b/app/components/autocomplete/at_mention/at_mention.js @@ -12,6 +12,8 @@ import { } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; +import {sortByUsername} from 'mattermost-redux/utils/user_utils'; + import FormattedText from 'app/components/formatted_text'; import ProfilePicture from 'app/components/profile_picture'; import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; @@ -19,6 +21,7 @@ import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; import {RequestStatus} from 'mattermost-redux/constants'; const AT_MENTION_REGEX = /\B(@([^@\r\n\s]*))$/i; +const FROM_REGEX = /\bfrom:\s*(\S*)$/i; export default class AtMention extends Component { static propTypes = { @@ -27,7 +30,8 @@ export default class AtMention extends Component { currentTeamId: PropTypes.string.isRequired, cursorPosition: PropTypes.number.isRequired, defaultChannel: PropTypes.object.isRequired, - autocompleteUsersInCurrentChannel: PropTypes.object.isRequired, + autocompleteUsers: PropTypes.object.isRequired, + isSearch: PropTypes.bool, postDraft: PropTypes.string, requestStatus: PropTypes.string.isRequired, theme: PropTypes.object.isRequired, @@ -38,9 +42,10 @@ export default class AtMention extends Component { }; static defaultProps = { - autocompleteUsersInCurrentChannel: {}, + autocompleteUsers: {}, defaultChannel: {}, - postDraft: '' + postDraft: '', + isSearch: false }; constructor(props) { @@ -58,7 +63,9 @@ export default class AtMention extends Component { } componentWillReceiveProps(nextProps) { - const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(AT_MENTION_REGEX); + const {isSearch} = nextProps; + const regex = isSearch ? FROM_REGEX : AT_MENTION_REGEX; + const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex); if (!match || this.state.mentionComplete) { this.setState({ @@ -69,8 +76,7 @@ export default class AtMention extends Component { return; } - const matchTerm = match[2]; - + const matchTerm = isSearch ? match[1] : match[2]; if (matchTerm !== this.state.matchTerm) { this.setState({ matchTerm @@ -81,30 +87,35 @@ export default class AtMention extends Component { } if (nextProps.requestStatus !== RequestStatus.STARTED) { - const membersInChannel = this.filter(nextProps.autocompleteUsersInCurrentChannel.inChannel, matchTerm) || []; - const membersOutOfChannel = this.filter(nextProps.autocompleteUsersInCurrentChannel.outChannel, matchTerm) || []; + const membersInChannel = this.filter(nextProps.autocompleteUsers.inChannel, matchTerm) || []; + const membersOutOfChannel = this.filter(nextProps.autocompleteUsers.outChannel, matchTerm) || []; let data = {}; - if (membersInChannel.length > 0) { - data = Object.assign({}, data, {inChannel: membersInChannel}); - } - if (this.checkSpecialMentions(matchTerm)) { - data = Object.assign({}, data, {specialMentions: this.getSpecialMentions()}); - } - if (membersOutOfChannel.length > 0) { - data = Object.assign({}, data, {notInChannel: membersOutOfChannel}); + if (isSearch) { + data = {members: membersInChannel.concat(membersOutOfChannel).sort(sortByUsername)}; + } else { + if (membersInChannel.length > 0) { + data = Object.assign({}, data, {inChannel: membersInChannel}); + } + if (this.checkSpecialMentions(matchTerm) && !isSearch) { + data = Object.assign({}, data, {specialMentions: this.getSpecialMentions()}); + } + if (membersOutOfChannel.length > 0) { + data = Object.assign({}, data, {notInChannel: membersOutOfChannel}); + } } this.setState({ - active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel'), + active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel') || data.hasOwnProperty('members'), dataSource: this.state.dataSource.cloneWithRowsAndSections(data) }); } } filter = (profiles, matchTerm) => { + const {isSearch} = this.props; return profiles.filter((p) => { - return ((p.id !== this.props.currentUserId) && ( + return ((p.id !== this.props.currentUserId || isSearch) && ( p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) || p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm))); }); @@ -134,14 +145,21 @@ export default class AtMention extends Component { }; completeMention = (mention) => { - const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition); + const {cursorPosition, isSearch, onChangeText, postDraft} = this.props; + const mentionPart = postDraft.substring(0, cursorPosition); - let completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `); - if (this.props.postDraft.length > this.props.cursorPosition) { - completedDraft += this.props.postDraft.substring(this.props.cursorPosition); + let completedDraft; + if (isSearch) { + completedDraft = mentionPart.replace(FROM_REGEX, `from: ${mention} `); + } else { + completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `); } - this.props.onChangeText(completedDraft); + if (postDraft.length > cursorPosition) { + completedDraft += postDraft.substring(cursorPosition); + } + + onChangeText(completedDraft); this.setState({ active: false, mentionComplete: true @@ -163,6 +181,10 @@ export default class AtMention extends Component { specialMentions: { id: 'suggestion.mention.special', defaultMessage: 'Special Mentions' + }, + members: { + id: 'mobile.suggestion.members', + defaultMessage: 'Members' } }; @@ -236,7 +258,7 @@ export default class AtMention extends Component { }; render() { - const {autocompleteUsersInCurrentChannel, requestStatus} = this.props; + const {autocompleteUsers, requestStatus} = this.props; if (!this.state.active && (requestStatus !== RequestStatus.STARTED || requestStatus !== RequestStatus.SUCCESS)) { // If we are not in an active state return null so nothing is rendered // other components are not blocked. @@ -246,14 +268,14 @@ export default class AtMention extends Component { const style = getStyleFromTheme(this.props.theme); if ( - !autocompleteUsersInCurrentChannel.inChannel && - !autocompleteUsersInCurrentChannel.outChannel && + !autocompleteUsers.inChannel && + !autocompleteUsers.outChannel && requestStatus === RequestStatus.STARTED ) { return ( diff --git a/app/components/autocomplete/at_mention/index.js b/app/components/autocomplete/at_mention/index.js index aae27aa18..76ae173a3 100644 --- a/app/components/autocomplete/at_mention/index.js +++ b/app/components/autocomplete/at_mention/index.js @@ -15,7 +15,9 @@ function mapStateToProps(state, ownProps) { const {currentChannelId} = state.entities.channels; let postDraft; - if (ownProps.rootId.length) { + if (ownProps.isSearch) { + postDraft = state.views.search; + } else if (ownProps.rootId.length) { const threadDraft = state.views.thread.drafts[ownProps.rootId]; if (threadDraft) { postDraft = threadDraft.draft; @@ -28,18 +30,18 @@ function mapStateToProps(state, ownProps) { } return { - ...ownProps, currentUserId: state.entities.users.currentUserId, currentChannelId, currentTeamId: state.entities.teams.currentTeamId, defaultChannel: getDefaultChannel(state), postDraft, - autocompleteUsersInCurrentChannel: { + autocompleteUsers: { inChannel: getProfilesInCurrentChannel(state), outChannel: getProfilesNotInCurrentChannel(state) }, requestStatus: state.requests.users.autocompleteUsers.status, - theme: getTheme(state) + theme: getTheme(state), + ...ownProps }; } diff --git a/app/components/autocomplete/channel_mention/channel_mention.js b/app/components/autocomplete/channel_mention/channel_mention.js index df35d75d7..ea1d478a4 100644 --- a/app/components/autocomplete/channel_mention/channel_mention.js +++ b/app/components/autocomplete/channel_mention/channel_mention.js @@ -17,6 +17,7 @@ import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; import {RequestStatus} from 'mattermost-redux/constants'; const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i; +const CHANNEL_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i; export default class ChannelMention extends Component { static propTypes = { @@ -25,6 +26,7 @@ export default class ChannelMention extends Component { cursorPosition: PropTypes.number.isRequired, autocompleteChannels: PropTypes.object.isRequired, postDraft: PropTypes.string, + isSearch: PropTypes.bool, requestStatus: PropTypes.string.isRequired, theme: PropTypes.object.isRequired, onChangeText: PropTypes.func.isRequired, @@ -34,7 +36,8 @@ export default class ChannelMention extends Component { }; static defaultProps = { - postDraft: '' + postDraft: '', + isSearch: false }; constructor(props) { @@ -52,7 +55,9 @@ export default class ChannelMention extends Component { } componentWillReceiveProps(nextProps) { - const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(CHANNEL_MENTION_REGEX); + const {isSearch} = nextProps; + const regex = isSearch ? CHANNEL_SEARCH_REGEX : CHANNEL_MENTION_REGEX; + const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex); // If not match or if user clicked on a channel if (!match || this.state.mentionComplete) { @@ -70,7 +75,7 @@ export default class ChannelMention extends Component { return; } - const matchTerm = match[2]; + const matchTerm = isSearch ? match[1] : match[2]; const myChannels = this.filter(nextProps.autocompleteChannels.myChannels, matchTerm); const otherChannels = this.filter(nextProps.autocompleteChannels.otherChannels, matchTerm); @@ -84,7 +89,14 @@ export default class ChannelMention extends Component { } // Still matching the same term that didn't return any results - if (match[0].startsWith(`~${this.state.matchTerm}`) && (myChannels.length === 0 && otherChannels.length === 0)) { + let startsWith; + if (isSearch) { + startsWith = match[0].startsWith(`in:${this.state.matchTerm}`) || match[0].startsWith(`channel:${this.state.matchTerm}`); + } else { + startsWith = match[0].startsWith(`~${this.state.matchTerm}`); + } + + if (startsWith && (myChannels.length === 0 && otherChannels.length === 0)) { this.setState({ active: false }); @@ -123,14 +135,22 @@ export default class ChannelMention extends Component { }; completeMention = (mention) => { - const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition); + const {cursorPosition, isSearch, onChangeText, postDraft} = this.props; + const mentionPart = postDraft.substring(0, cursorPosition); - let completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `); - if (this.props.postDraft.length > this.props.cursorPosition) { - completedDraft += this.props.postDraft.substring(this.props.cursorPosition); + let completedDraft; + if (isSearch) { + const channelOrIn = mentionPart.includes('in:') ? 'in:' : 'channel:'; + completedDraft = mentionPart.replace(CHANNEL_SEARCH_REGEX, `${channelOrIn} ${mention} `); + } else { + completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `); } - this.props.onChangeText(completedDraft); + if (postDraft.length > cursorPosition) { + completedDraft += postDraft.substring(cursorPosition); + } + + onChangeText(completedDraft); this.setState({ active: false, mentionComplete: true, diff --git a/app/components/autocomplete/channel_mention/index.js b/app/components/autocomplete/channel_mention/index.js index d0b2614bd..2cb9d267f 100644 --- a/app/components/autocomplete/channel_mention/index.js +++ b/app/components/autocomplete/channel_mention/index.js @@ -16,7 +16,9 @@ function mapStateToProps(state, ownProps) { const {currentChannelId} = state.entities.channels; let postDraft; - if (ownProps.rootId.length) { + if (ownProps.isSearch) { + postDraft = state.views.search; + } else if (ownProps.rootId.length) { const threadDraft = state.views.thread.drafts[ownProps.rootId]; if (threadDraft) { postDraft = threadDraft.draft; diff --git a/app/components/autocomplete/index.js b/app/components/autocomplete/index.js index e2fc96382..4cf28ffd5 100644 --- a/app/components/autocomplete/index.js +++ b/app/components/autocomplete/index.js @@ -19,13 +19,27 @@ const style = StyleSheet.create({ right: 0, maxHeight: 200, overflow: 'hidden' + }, + searchContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + maxHeight: 300, + overflow: 'hidden', + zIndex: 5 } }); export default class Autocomplete extends Component { static propTypes = { onChangeText: PropTypes.func.isRequired, - rootId: PropTypes.string + rootId: PropTypes.string, + isSearch: PropTypes.bool + }; + + static defaultProps = { + isSearch: false }; state = { @@ -39,18 +53,17 @@ export default class Autocomplete extends Component { }; render() { + const container = this.props.isSearch ? style.searchContainer : style.container; return ( - + diff --git a/app/components/markdown/index.js b/app/components/markdown/index.js index bbfc0e7ba..418777d3c 100644 --- a/app/components/markdown/index.js +++ b/app/components/markdown/index.js @@ -29,8 +29,10 @@ export default class Markdown extends PureComponent { baseTextStyle: CustomPropTypes.Style, blockStyles: PropTypes.object, emojiSizes: PropTypes.object, + isSearchResult: PropTypes.bool, navigator: PropTypes.object.isRequired, onLongPress: PropTypes.func, + onPostPress: PropTypes.func, textStyles: PropTypes.object, value: PropTypes.string.isRequired }; @@ -134,7 +136,9 @@ export default class Markdown extends PureComponent { ); diff --git a/app/components/offline_indicator/offline_indicator.js b/app/components/offline_indicator/offline_indicator.js index fee55dffb..aeff48aa2 100644 --- a/app/components/offline_indicator/offline_indicator.js +++ b/app/components/offline_indicator/offline_indicator.js @@ -210,7 +210,8 @@ const styles = StyleSheet.create({ flex: 1, height: HEIGHT, flexDirection: 'row', - paddingHorizontal: 12, + paddingLeft: 12, + paddingRight: 5, backgroundColor: 'red' }, message: { @@ -220,13 +221,15 @@ const styles = StyleSheet.create({ flex: 1 }, actionButton: { + alignItems: 'center', borderWidth: 1, borderColor: '#FFFFFF' }, actionContainer: { - alignItems: 'center', + alignItems: 'flex-end', height: 24, justifyContent: 'center', + paddingRight: 10, width: 60 } }); diff --git a/app/components/post/index.js b/app/components/post/index.js index 0d9c9f8d5..bdcea112b 100644 --- a/app/components/post/index.js +++ b/app/components/post/index.js @@ -26,6 +26,7 @@ function makeMapStateToProps() { post, config, currentUserId: getCurrentUserId(state), + highlight: ownProps.post.highlight, license, roles, theme: getTheme(state), diff --git a/app/components/post/post.js b/app/components/post/post.js index 9711c84fa..d65c65c5f 100644 --- a/app/components/post/post.js +++ b/app/components/post/post.js @@ -36,19 +36,27 @@ class Post extends PureComponent { }).isRequired, config: PropTypes.object.isRequired, currentUserId: PropTypes.string.isRequired, + highlight: PropTypes.bool, intl: intlShape.isRequired, style: ViewPropTypes.style, post: PropTypes.object.isRequired, renderReplies: PropTypes.bool, isFirstReply: PropTypes.bool, isLastReply: PropTypes.bool, + isSearchResult: PropTypes.bool, commentedOnPost: PropTypes.object, license: PropTypes.object.isRequired, navigator: PropTypes.object, roles: PropTypes.string, + shouldRenderReplyButton: PropTypes.bool, tooltipVisible: PropTypes.bool, theme: PropTypes.object.isRequired, - onPress: PropTypes.func + onPress: PropTypes.func, + onReply: PropTypes.func + }; + + static defaultProps = { + isSearchResult: false }; constructor(props) { @@ -201,6 +209,15 @@ class Post extends PureComponent { } }; + handleReply = () => { + const {post, onReply, tooltipVisible} = this.props; + if (!tooltipVisible && onReply) { + return preventDoubleTap(onReply, null, post); + } + + return this.handlePress(); + }; + onRemovePost = (post) => { const {removePost} = this.props.actions; removePost(post); @@ -235,7 +252,11 @@ class Post extends PureComponent { }; viewUserProfile = () => { - preventDoubleTap(this.goToUserProfile, this); + const {isSearchResult} = this.props; + + if (!isSearchResult) { + preventDoubleTap(this.goToUserProfile, this); + } }; toggleSelected = (selected) => { @@ -246,16 +267,20 @@ class Post extends PureComponent { render() { const { commentedOnPost, + highlight, isLastReply, + isSearchResult, post, renderReplies, + shouldRenderReplyButton, theme } = this.props; const style = getStyleSheet(theme); const selected = this.state && this.state.selected ? style.selected : null; + const highlighted = highlight ? style.highlight : null; return ( - + { }, selected: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.1) + }, + highlight: { + backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5) } }); }); diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index 367f43dc1..5871e895f 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import { Platform, StyleSheet, + TouchableHighlight, TouchableOpacity, View } from 'react-native'; @@ -39,6 +40,7 @@ class PostBody extends PureComponent { isFailed: PropTypes.bool, isFlagged: PropTypes.bool, isPending: PropTypes.bool, + isSearchResult: PropTypes.bool, isSystemMessage: PropTypes.bool, message: PropTypes.string, navigator: PropTypes.object.isRequired, @@ -62,8 +64,16 @@ class PostBody extends PureComponent { toggleSelected: emptyFunction }; + handleHideUnderlay = () => { + this.props.toggleSelected(false); + }; + + handleShowUnderlay = () => { + this.props.toggleSelected(true); + }; + hideOptionsContext = () => { - if (Platform.OS === 'ios') { + if (Platform.OS === 'ios' && this.refs.options) { this.refs.options.hide(); } }; @@ -79,7 +89,9 @@ class PostBody extends PureComponent { }; showOptionsContext = () => { - return this.refs.options.show(); + if (this.refs.options) { + this.refs.options.show(); + } }; renderFileAttachments() { @@ -138,6 +150,7 @@ class PostBody extends PureComponent { isFailed, isFlagged, isPending, + isSearchResult, isSystemMessage, intl, message, @@ -160,7 +173,7 @@ class PostBody extends PureComponent { const isPendingOrFailedPost = isPending || isFailed; // we should check for the user roles and permissions - if (!isPendingOrFailedPost) { + if (!isPendingOrFailedPost && !isSearchResult) { if (isFlagged) { actions.push({ text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}), @@ -199,8 +212,10 @@ class PostBody extends PureComponent { baseTextStyle={messageStyle} textStyles={textStyles} blockStyles={blockStyles} + isSearchResult={isSearchResult} value={message} onLongPress={this.showOptionsContext} + onPostPress={onPress} navigator={navigator} /> @@ -208,23 +223,46 @@ class PostBody extends PureComponent { ); } + let body; + if (isSearchResult) { + body = ( + + + {messageComponent} + {this.renderSlackAttachments(messageStyle, blockStyles, textStyles)} + {this.renderFileAttachments()} + + + ); + } else { + body = ( + + {messageComponent} + {this.renderSlackAttachments(messageStyle, blockStyles, textStyles)} + {this.renderFileAttachments()} + {hasReactions && } + + ); + } + return ( {renderReplyBar()} - - {messageComponent} - {this.renderSlackAttachments(messageStyle, blockStyles, textStyles)} - {this.renderFileAttachments()} - {hasReactions && } - + {body} {isFailed && 0 && renderReplies); return ( @@ -155,7 +160,7 @@ export default class PostHeader extends PureComponent { - {(!commentedOnDisplayName && commentCount > 0 && renderReplies) && + {showReply && + {!isSearchResult && {commentCount} + } } diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index ba64994f4..62270892b 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -29,6 +29,7 @@ export default class PostList extends PureComponent { currentUserId: PropTypes.string, indicateNewMessages: PropTypes.bool, isLoadingMore: PropTypes.bool, + isSearchResult: PropTypes.bool, lastViewedAt: PropTypes.number, loadMore: PropTypes.func, navigator: PropTypes.object, @@ -37,6 +38,7 @@ export default class PostList extends PureComponent { refreshing: PropTypes.bool, renderReplies: PropTypes.bool, showLoadMore: PropTypes.bool, + shouldRenderReplyButton: PropTypes.bool, theme: PropTypes.object.isRequired }; @@ -128,15 +130,25 @@ export default class PostList extends PureComponent { }; renderPost = (post) => { + const { + isSearchResult, + navigator, + onPostPress, + renderReplies, + shouldRenderReplyButton + } = this.props; + return ( ); }; diff --git a/app/components/scrollable_section_list/index.js b/app/components/scrollable_section_list/index.js new file mode 100644 index 000000000..6ae35002b --- /dev/null +++ b/app/components/scrollable_section_list/index.js @@ -0,0 +1,30 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; +import {SectionList} from 'react-native'; +import MetroListView from 'react-native/Libraries/Lists/MetroListView'; +import VirtualizedSectionList from './virtualized_section_list'; + +export default class ScrollableSectionList extends SectionList { + getWrapperRef = () => { + return this._wrapperListRef; //eslint-disable-line no-underscore-dangle + }; + + render() { + const List = this.props.legacyImplementation ? + MetroListView : + VirtualizedSectionList; + return ( + + ); + } + + _wrapperListRef: MetroListView | VirtualizedSectionList; //eslint-disable-line no-underscore-dangle + _captureRef = (ref) => { + this._wrapperListRef = ref; //eslint-disable-line no-underscore-dangle + }; +} diff --git a/app/components/scrollable_section_list/virtualized_section_list.js b/app/components/scrollable_section_list/virtualized_section_list.js new file mode 100644 index 000000000..e54e8973e --- /dev/null +++ b/app/components/scrollable_section_list/virtualized_section_list.js @@ -0,0 +1,26 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; +import {VirtualizedList} from 'react-native'; +import VirtualizedSectionList from 'react-native/Libraries/Lists/VirtualizedSectionList'; + +export default class VirtualizedScrollableSectionList extends VirtualizedSectionList { + getListRef() { + return this._listRef; //eslint-disable-line no-underscore-dangle + } + + render() { + return ( + + ); + } + + _listRef: VirtualizedList; + _captureRef = (ref) => { + this._listRef = ref; //eslint-disable-line no-underscore-dangle + }; +} diff --git a/app/components/search_preview/index.js b/app/components/search_preview/index.js new file mode 100644 index 000000000..224347f6a --- /dev/null +++ b/app/components/search_preview/index.js @@ -0,0 +1,35 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getPost, makeGetPostsAroundPost} from 'mattermost-redux/selectors/entities/posts'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +import SearchPreview from './search_preview'; + +function makeMapStateToProps() { + const getPostsAroundPost = makeGetPostsAroundPost(); + + return function mapStateToProps(state, ownProps) { + const post = getPost(state, ownProps.focusedPostId); + const postsAroundPost = getPostsAroundPost(state, post.id, post.channel_id); + const focusedPostIndex = postsAroundPost ? postsAroundPost.findIndex((p) => p.id === ownProps.focusedPostId) : -1; + let posts = []; + + if (focusedPostIndex !== -1) { + const desiredPostIndexBefore = focusedPostIndex - 5; + const minPostIndex = desiredPostIndexBefore < 0 ? 0 : desiredPostIndexBefore; + posts = [...postsAroundPost].splice(minPostIndex, 10); + } + + return { + ...ownProps, + channelId: post.channel_id, + currentUserId: getCurrentUserId(state), + posts + }; + }; +} + +export default connect(makeMapStateToProps, null, null, {withRef: true})(SearchPreview); diff --git a/app/components/search_preview/search_preview.js b/app/components/search_preview/search_preview.js new file mode 100644 index 000000000..3f21d0d03 --- /dev/null +++ b/app/components/search_preview/search_preview.js @@ -0,0 +1,234 @@ +// 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 { + Dimensions, + Platform, + StyleSheet, + 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 {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 = { + channelId: PropTypes.string, + channelName: PropTypes.string, + currentUserId: PropTypes.string.isRequired, + navigator: PropTypes.object, + onClose: PropTypes.func, + onPress: PropTypes.func, + posts: PropTypes.array, + theme: PropTypes.object.isRequired + }; + + static defaultProps = { + posts: [] + }; + + state = { + showPosts: false + }; + + handleClose = () => { + this.refs.view.zoomOut().then(() => { + if (this.props.onClose) { + this.props.onClose(); + } + }); + return true; + }; + + handlePress = () => { + const {channelId, onPress} = this.props; + this.refs.view.growOut().then(() => { + if (onPress) { + onPress(channelId); + } + }); + }; + + showPostList = () => { + if (!this.state.showPosts && this.props.posts.length) { + this.setState({showPosts: true}); + } + }; + + render() { + const {channelName, currentUserId, posts, theme} = this.props; + const {height, width} = Dimensions.get('window'); + const style = getStyleSheet(theme); + + let postList; + if (this.state.showPosts) { + postList = ( + + ); + } else { + postList = ; + } + + return ( + + + + + + + + + {channelName} + + + + + {postList} + + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + container: { + position: 'absolute', + backgroundColor: changeOpacity('#000', 0.3), + top: 0, + left: 0, + zIndex: 10 + }, + wrapper: { + flex: 1, + marginHorizontal: 10, + opacity: 0, + ...Platform.select({ + android: { + marginTop: 10, + marginBottom: 35 + }, + ios: { + marginTop: 20, + marginBottom: 10 + } + }) + }, + 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.sidebarHeaderTextColor, + 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/constants/view.js b/app/constants/view.js index 17a132921..11e0722d0 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -14,6 +14,7 @@ const ViewTypes = keyMirror({ POST_DRAFT_CHANGED: null, COMMENT_DRAFT_CHANGED: null, + SEARCH_DRAFT_CHANGED: null, NOTIFICATION_CHANGED: null, NOTIFICATION_IN_APP: null, diff --git a/app/mattermost.js b/app/mattermost.js index 3ad0cd5e4..0a6200fd0 100644 --- a/app/mattermost.js +++ b/app/mattermost.js @@ -44,6 +44,12 @@ registerScreens(store, Provider); export default class Mattermost { constructor() { + if (Platform.OS === 'android') { + // This is to remove the warnings for the scaleY property used in android. + // The property is necessary because some Android devices won't render the posts + // properly if we use transform: {scaleY: -1} in the stylesheet. + console.ignoredYellowBox = ['`scaleY`']; //eslint-disable-line + } this.isConfigured = false; setJSExceptionHandler(this.errorHandler, false); Orientation.lockToPortrait(); diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js index 866492c5c..a0caf1b15 100644 --- a/app/reducers/views/index.js +++ b/app/reducers/views/index.js @@ -10,6 +10,7 @@ import i18n from './i18n'; import login from './login'; import notification from './notification'; import root from './root'; +import search from './search'; import selectServer from './select_server'; import team from './team'; import thread from './thread'; @@ -22,6 +23,7 @@ export default combineReducers({ login, notification, root, + search, selectServer, team, thread diff --git a/app/reducers/views/search.js b/app/reducers/views/search.js new file mode 100644 index 000000000..61b34e89a --- /dev/null +++ b/app/reducers/views/search.js @@ -0,0 +1,18 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ViewTypes} from 'app/constants'; +import {UserTypes} from 'mattermost-redux/action_types'; + +export default function search(state = '', action) { + switch (action.type) { + case ViewTypes.SEARCH_DRAFT_CHANGED: { + return action.text; + } + case UserTypes.LOGOUT_SUCCESS: + return ''; + + default: + return state; + } +} diff --git a/app/screens/channel/channel.js b/app/screens/channel/channel.js index bc9a42d4c..d6cc1afdd 100644 --- a/app/screens/channel/channel.js +++ b/app/screens/channel/channel.js @@ -28,6 +28,7 @@ import {makeStyleSheetFromTheme} from 'app/utils/theme'; import ChannelDrawerButton from './channel_drawer_button'; import ChannelTitle from './channel_title'; import ChannelPostList from './channel_post_list'; +import ChannelSearchButton from './channel_search_button'; class Channel extends PureComponent { static propTypes = { @@ -239,6 +240,7 @@ class Channel extends PureComponent { preventDoubleTap(this.goToChannelInfo, this)} /> + diff --git a/app/screens/channel/channel_drawer_button.js b/app/screens/channel/channel_drawer_button.js index 429350135..28fa05447 100644 --- a/app/screens/channel/channel_drawer_button.js +++ b/app/screens/channel/channel_drawer_button.js @@ -154,6 +154,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { flexDirection: 'column', justifyContent: 'center', paddingHorizontal: 10, + paddingTop: 5, zIndex: 30 }, badge: { diff --git a/app/screens/channel/channel_post_list/channel_post_list.js b/app/screens/channel/channel_post_list/channel_post_list.js index 85e98110b..2da5f724e 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.js +++ b/app/screens/channel/channel_post_list/channel_post_list.js @@ -24,6 +24,7 @@ class ChannelPostList extends PureComponent { static propTypes = { actions: PropTypes.shape({ loadPostsIfNecessary: PropTypes.func.isRequired, + loadThreadIfNecessary: PropTypes.func.isRequired, increasePostVisibility: PropTypes.func.isRequired, selectPost: PropTypes.func.isRequired }).isRequired, @@ -98,6 +99,7 @@ class ChannelPostList extends PureComponent { const channelId = post.channel_id; const rootId = (post.root_id || post.id); + actions.loadThreadIfNecessary(post.root_id); actions.selectPost(rootId); let title; diff --git a/app/screens/channel/channel_post_list/index.js b/app/screens/channel/channel_post_list/index.js index 2f7a97998..3c0c98c66 100644 --- a/app/screens/channel/channel_post_list/index.js +++ b/app/screens/channel/channel_post_list/index.js @@ -9,7 +9,7 @@ import {RequestStatus} from 'mattermost-redux/constants'; import {makeGetPostsInChannel} from 'mattermost-redux/selectors/entities/posts'; import {getCurrentChannelId, getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels'; -import {loadPostsIfNecessary, increasePostVisibility} from 'app/actions/views/channel'; +import {loadPostsIfNecessary, loadThreadIfNecessary, increasePostVisibility} from 'app/actions/views/channel'; import {getTheme} from 'app/selectors/preferences'; import ChannelPostList from './channel_post_list'; @@ -42,6 +42,7 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ loadPostsIfNecessary, + loadThreadIfNecessary, increasePostVisibility, selectPost }, dispatch) diff --git a/app/screens/channel/channel_search_button.js b/app/screens/channel/channel_search_button.js new file mode 100644 index 000000000..4946d54cc --- /dev/null +++ b/app/screens/channel/channel_search_button.js @@ -0,0 +1,116 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +// 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 {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; +import { + StyleSheet, + TouchableOpacity, + View +} from 'react-native'; +import AwesomeIcon from 'react-native-vector-icons/FontAwesome'; + +import {clearSearch} from 'mattermost-redux/actions/search'; + +import {handlePostDraftChanged} from 'app/actions/views/channel'; +import {getTheme} from 'app/selectors/preferences'; +import {preventDoubleTap} from 'app/utils/tap'; + +const SEARCH = 'search'; + +class ChannelSearchButton extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + clearSearch: PropTypes.func.isRequired, + handlePostDraftChanged: PropTypes.func.isRequired + }).isRequired, + applicationInitializing: PropTypes.bool.isRequired, + navigator: PropTypes.object, + theme: PropTypes.object + }; + + handlePress = async () => { + const {actions, navigator, theme} = this.props; + + await actions.clearSearch(); + actions.handlePostDraftChanged(SEARCH, ''); + + navigator.showModal({ + screen: 'Search', + animated: true, + backButtonTitle: '', + overrideBackPress: true, + navigatorStyle: { + navBarHidden: true, + screenBackgroundColor: theme.centerChannelBg + }, + passProps: { + theme + } + }); + }; + + render() { + const { + applicationInitializing, + theme + } = this.props; + + if (applicationInitializing) { + return null; + } + + return ( + preventDoubleTap(this.handlePress, this)} + style={style.container} + > + + + + + ); + } +} + +const style = StyleSheet.create({ + container: { + width: 40 + }, + wrapper: { + alignItems: 'center', + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + paddingHorizontal: 10, + zIndex: 30 + } +}); + +function mapStateToProps(state, ownProps) { + return { + applicationInitializing: state.views.root.appInitializing, + theme: getTheme(state), + ...ownProps + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + clearSearch, + handlePostDraftChanged + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ChannelSearchButton); diff --git a/app/screens/channel/channel_title.js b/app/screens/channel/channel_title.js index 4f244405e..a3d943a69 100644 --- a/app/screens/channel/channel_title.js +++ b/app/screens/channel/channel_title.js @@ -35,7 +35,7 @@ function ChannelTitle(props) { return ( diff --git a/app/screens/index.js b/app/screens/index.js index 8beb50ca6..bb216b6f8 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -24,6 +24,7 @@ import Notification from 'app/screens/notification'; import OptionsModal from 'app/screens/options_modal'; import Root from 'app/screens/root'; import SSO from 'app/screens/sso'; +import Search from 'app/screens/search'; import SelectServer from 'app/screens/select_server'; import SelectTeam from 'app/screens/select_team'; import Settings from 'app/screens/settings'; @@ -66,6 +67,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('OptionsModal', () => wrapWithContextProvider(OptionsModal), store, Provider); Navigation.registerComponent('Notification', () => wrapWithContextProvider(Notification, true), store, Provider); Navigation.registerComponent('Root', () => Root, store, Provider); + Navigation.registerComponent('Search', () => wrapWithContextProvider(Search), store, Provider); Navigation.registerComponent('SelectServer', () => wrapWithContextProvider(SelectServer), store, Provider); Navigation.registerComponent('SelectTeam', () => wrapWithContextProvider(SelectTeam), store, Provider); Navigation.registerComponent('Settings', () => wrapWithContextProvider(Settings), store, Provider); diff --git a/app/screens/search/index.js b/app/screens/search/index.js new file mode 100644 index 000000000..dd941d431 --- /dev/null +++ b/app/screens/search/index.js @@ -0,0 +1,63 @@ +// 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 {viewChannel, markChannelAsRead} from 'mattermost-redux/actions/channels'; +import {getPostsAfter, getPostsBefore, getPostThread, selectPost} from 'mattermost-redux/actions/posts'; +import {clearSearch, removeSearchTerms, searchPosts} from 'mattermost-redux/actions/search'; +import {RequestStatus} from 'mattermost-redux/constants'; +import {getCurrentChannelId, getMyChannels} from 'mattermost-redux/selectors/entities/channels'; +import {getSearchResults} from 'mattermost-redux/selectors/entities/posts'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import { + handleSelectChannel, + loadThreadIfNecessary, + setChannelDisplayName, + setChannelLoading +} from 'app/actions/views/channel'; +import {handleSearchDraftChanged} from 'app/actions/views/search'; + +import Search from './search'; + +function mapStateToProps(state, ownProps) { + const currentTeamId = getCurrentTeamId(state); + const currentChannelId = getCurrentChannelId(state); + const {recent} = state.entities.search; + const {searchPosts: searchRequest} = state.requests.search; + + return { + ...ownProps, + currentTeamId, + currentChannelId, + posts: getSearchResults(state), + recent: recent[currentTeamId] || [], + channels: getMyChannels(state), + searching: searchRequest.status === RequestStatus.STARTED + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + clearSearch, + getPostsAfter, + getPostsBefore, + getPostThread, + handleSearchDraftChanged, + handleSelectChannel, + loadThreadIfNecessary, + markChannelAsRead, + removeSearchTerms, + searchPosts, + selectPost, + setChannelDisplayName, + setChannelLoading, + viewChannel + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/screens/search/search.js b/app/screens/search/search.js new file mode 100644 index 000000000..44bb9c195 --- /dev/null +++ b/app/screens/search/search.js @@ -0,0 +1,574 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {injectIntl, intlShape} from 'react-intl'; +import { + Dimensions, + Platform, + StyleSheet, + Text, + TouchableHighlight, + TouchableOpacity, + View +} from 'react-native'; +import IonIcon from 'react-native-vector-icons/Ionicons'; + +import {General} from 'mattermost-redux/constants'; + +import Autocomplete from 'app/components/autocomplete'; +import Loading from 'app/components/loading'; +import Post from 'app/components/post'; +import SectionList from 'app/components/scrollable_section_list'; +import SearchBar from 'app/components/search_bar'; +import SearchPreview from 'app/components/search_preview'; +import StatusBar from 'app/components/status_bar'; +import {ViewTypes} from 'app/constants'; +import {preventDoubleTap} from 'app/utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +const SECTION_HEIGHT = 20; +const RECENT_LABEL_HEIGHT = 42; +const RECENT_SEPARATOR_HEIGHT = 3; +const POSTS_PER_PAGE = ViewTypes.POST_VISIBILITY_CHUNK_SIZE; +const SEARCHING = 'searching'; + +class Search extends Component { + static propTypes = { + actions: PropTypes.shape({ + clearSearch: PropTypes.func.isRequired, + getPostsAfter: PropTypes.func.isRequired, + getPostsBefore: PropTypes.func.isRequired, + getPostThread: PropTypes.func.isRequired, + handleSearchDraftChanged: PropTypes.func.isRequired, + loadThreadIfNecessary: PropTypes.func.isRequired, + removeSearchTerms: PropTypes.func.isRequired, + searchPosts: PropTypes.func.isRequired, + selectPost: PropTypes.func.isRequired + }).isRequired, + channels: PropTypes.array.isRequired, + currentTeamId: PropTypes.string.isRequired, + currentChannelId: PropTypes.string.isRequired, + intl: intlShape.isRequired, + navigator: PropTypes.object, + posts: PropTypes.array, + recent: PropTypes.array.isRequired, + searching: PropTypes.bool, + theme: PropTypes.object.isRequired + }; + + static defaultProps = { + posts: [] + }; + + constructor(props) { + super(props); + + props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); + this.state = { + channelName: '', + isFocused: true, + postId: null, + preview: false, + value: '' + }; + } + + componentDidMount() { + this.refs.searchBar.focus(); + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.recent !== nextProps.recent || + this.props.posts !== nextProps.posts || + this.state !== nextState + ); + } + + componentDidUpdate() { + const {posts, recent} = this.props; + const recentLenght = recent.length; + + if (posts.length && !this.state.isFocused) { + this.refs.list.getWrapperRef().getListRef().scrollToOffset({ + animated: true, + offset: SECTION_HEIGHT + (recentLenght * RECENT_LABEL_HEIGHT) + ((recentLenght - 1) * RECENT_SEPARATOR_HEIGHT) + }); + } + } + + attachAutocomplete = (c) => { + this.autocomplete = c; + }; + + cancelSearch = () => { + const {navigator} = this.props; + this.handleTextChanged(''); + navigator.dismissModal({animationType: 'slide-down'}); + }; + + goToThread = (post) => { + const {actions, channels, intl, navigator, theme} = this.props; + const channelId = post.channel_id; + const channel = channels.find((c) => c.id === channelId); + const rootId = (post.root_id || post.id); + + actions.loadThreadIfNecessary(post.root_id); + actions.selectPost(rootId); + + let title; + if (channel.type === General.DM_CHANNEL) { + title = intl.formatMessage({id: 'mobile.routes.thread_dm', defaultMessage: 'Direct Message Thread'}); + } else { + const channelName = channel.display_name; + title = intl.formatMessage({id: 'mobile.routes.thread', defaultMessage: '{channelName} Thread'}, {channelName}); + } + + const options = { + screen: 'Thread', + title, + animated: true, + backButtonTitle: '', + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg + }, + passProps: { + channelId, + rootId + } + }; + + if (Platform.OS === 'android') { + navigator.showModal(options); + } else { + navigator.push(options); + } + }; + + handleSelectionChange = (event) => { + if (this.autocomplete) { + this.autocomplete.handleSelectionChange(event); + } + }; + + handleTextChanged = (value) => { + const {actions, posts} = this.props; + this.setState({value}); + actions.handleSearchDraftChanged(value); + + if (!value && posts.length) { + actions.clearSearch(); + } + }; + + keyRecentExtractor = (item) => { + return `recent-${item.terms}`; + }; + + keyPostExtractor = (item) => { + return `result-${item.id}`; + }; + + onBlur = () => { + this.setState({isFocused: false}); + }; + + onFocus = () => { + const {posts} = this.props; + this.setState({isFocused: true}); + if (posts.length) { + this.refs.list.getWrapperRef().getListRef().scrollToOffset({ + animated: false, + offset: 0 + }); + } + }; + + onNavigatorEvent = (event) => { + if (event.id === 'backPress') { + if (this.state.preview) { + this.refs.preview.getWrappedInstance().handleClose(); + } else { + this.props.navigator.dismissModal(); + } + } + }; + + previewPost = (post) => { + const {actions, channels} = this.props; + const focusedPostId = post.id; + const channelId = post.channel_id; + + actions.getPostThread(focusedPostId); + actions.getPostsBefore(channelId, focusedPostId, 0, POSTS_PER_PAGE); + actions.getPostsAfter(channelId, focusedPostId, 0, POSTS_PER_PAGE); + + const channel = channels.find((c) => c.id === channelId); + let displayName = ''; + + if (channel) { + displayName = channel.display_name; + } + + this.setState({preview: true, postId: focusedPostId, channelName: displayName}); + }; + + removeSearchTerms = (item) => { + const {actions, currentTeamId} = this.props; + actions.removeSearchTerms(currentTeamId, item.terms); + }; + + renderPost = ({item}) => { + if (item.id === SEARCHING) { + return ( + + {item.component} + + ); + } + + const {channels, theme} = this.props; + const style = getStyleFromTheme(theme); + + const channel = channels.find((c) => c.id === item.channel_id); + let displayName = ''; + + if (channel) { + displayName = channel.display_name; + } + + return ( + + + {displayName} + + + + ); + }; + + renderRecentSeparator = () => { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + return ( + + + + ); + }; + + renderPostSeparator = () => { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + return ( + + + + ); + }; + + renderSectionHeader = ({section}) => { + const {theme} = this.props; + const {title} = section; + const style = getStyleFromTheme(theme); + + return ( + + + + {title} + + + + ); + }; + + renderRecentItem = ({item}) => { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + return ( + preventDoubleTap(this.setRecentValue, this, item)} + > + + + {item.terms} + + preventDoubleTap(this.removeSearchTerms, this, item)} + style={style.recentRemove} + > + + + + + ); + }; + + search = (terms, isOrSearch) => { + const {actions, currentTeamId} = this.props; + actions.searchPosts(currentTeamId, terms, isOrSearch); + }; + + setRecentValue = (recent) => { + const {terms, isOrSearch} = recent; + this.handleTextChanged(terms); + this.search(terms, isOrSearch); + this.refs.searchBar.blur(); + }; + + handleClosePreview = () => { + // console.warn('close preview'); + this.setState({preview: false, postId: null}); + }; + + handleJumpToChannel = (channelId) => { + if (channelId) { + const {actions, channels, currentChannelId} = this.props; + const { + handleSelectChannel, + markChannelAsRead, + setChannelLoading, + setChannelDisplayName, + viewChannel + } = actions; + + const channel = channels.find((c) => c.id === channelId); + let displayName = ''; + + if (channel) { + displayName = channel.display_name; + } + + this.props.navigator.dismissModal({animationType: 'none'}); + setChannelLoading(); + markChannelAsRead(channelId, currentChannelId); + viewChannel(channelId, currentChannelId); + setChannelDisplayName(displayName); + handleSelectChannel(channelId); + } + }; + + render() { + const { + intl, + navigator, + posts, + recent, + searching, + theme + } = this.props; + + const {channelName, postId, preview, value} = this.state; + const style = getStyleFromTheme(theme); + const sections = []; + + if (recent.length) { + sections.push({ + data: recent, + key: 'recent', + title: intl.formatMessage({id: 'mobile.search.recentTitle', defaultMessage: 'Recent Searches'}), + renderItem: this.renderRecentItem, + keyExtractor: this.keyRecentExtractor, + ItemSeparatorComponent: this.renderRecentSeparator + }); + } + + let results; + if (searching) { + results = [{ + id: SEARCHING, + component: + }]; + } else if (posts.length) { + results = posts; + } + + if (results) { + sections.push({ + data: results, + key: 'results', + title: intl.formatMessage({id: 'search_header.results', defaultMessage: 'Search Results'}), + renderItem: this.renderPost, + keyExtractor: this.keyPostExtractor, + ItemSeparatorComponent: this.renderPostSeparator + }); + } + + let previewComponent; + if (preview) { + previewComponent = ( + + ); + } + + return ( + + + + preventDoubleTap(this.search, this, text)} + onCancelButtonPress={() => preventDoubleTap(this.cancelSearch, this)} + onSelectionChange={this.handleSelectionChange} + autoCapitalize='none' + value={value} + containerStyle={{padding: 0}} + backArrowSize={28} + /> + + + + {previewComponent} + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + header: { + backgroundColor: theme.sidebarHeaderBg, + width: Dimensions.get('window').width, + ...Platform.select({ + android: { + height: 46, + justifyContent: 'center' + }, + ios: { + height: 64, + paddingTop: 20 + } + }) + }, + sectionWrapper: { + backgroundColor: theme.centerChannelBg + }, + sectionContainer: { + justifyContent: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.07), + paddingLeft: 16, + height: SECTION_HEIGHT + }, + sectionLabel: { + color: theme.centerChannelColor, + fontSize: 12, + fontWeight: '600' + }, + recentItemContainer: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + height: RECENT_LABEL_HEIGHT + }, + recentItemLabel: { + color: theme.centerChannelColor, + fontSize: 14, + height: 20, + flex: 1, + paddingHorizontal: 16 + }, + recentRemove: { + alignItems: 'center', + height: RECENT_LABEL_HEIGHT, + justifyContent: 'center', + width: 50 + }, + separatorContainer: { + justifyContent: 'center', + flex: 1, + height: RECENT_SEPARATOR_HEIGHT + }, + postsSeparator: { + height: 15 + }, + separator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + height: StyleSheet.hairlineWidth + }, + channelName: { + color: changeOpacity(theme.centerChannelColor, 0.8), + fontSize: 14, + fontWeight: '600', + marginTop: 5, + paddingHorizontal: 16 + }, + sectionList: { + flex: 1, + zIndex: -1 + } + }); +}); + +export default injectIntl(Search); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 87ec7486b..060c0c691 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1775,6 +1775,7 @@ "mobile.routes.thread_dm": "Direct Message Thread", "mobile.routes.user_profile": "Profile", "mobile.routes.user_profile.send_message": "Send Message", + "mobile.search.jump": "JUMP", "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.", @@ -1788,6 +1789,7 @@ "mobile.settings.clear_button": "Clear", "mobile.settings.clear_message": "\nThis will clear all offline data and restart the app. You will be automatically logged back in once the app restarts.\n", "mobile.settings.team_selection": "Team Selection", + "mobile.suggestion.members": "Members", "modal.manaul_status.ask": "Do not ask me again", "modal.manaul_status.button": "Yes, set my status to \"Online\"", "modal.manaul_status.cancel": "No, keep it as \"{status}\"", diff --git a/package.json b/package.json index 0426aa4f7..4034bc11d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "react": "16.0.0-alpha.6", "react-intl": "2.3.0", "react-native": "0.44.0", + "react-native-animatable": "1.2.2", "react-native-bottom-sheet": "1.0.1", "react-native-button": "1.8.2", "react-native-cookies": "3.1.0", diff --git a/yarn.lock b/yarn.lock index 7868c0487..d9ba6892a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3645,7 +3645,7 @@ makeerror@1.0.x: mattermost-redux@mattermost/mattermost-redux#master: version "0.0.1" - resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/0d2b682cabc3893a0124f6674bcbfb653eead4a5" + resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/81416da8fd9f2df12286a732c7e98fd031dfd329" dependencies: deep-equal "1.0.1" harmony-reflect "1.5.1" @@ -4452,6 +4452,10 @@ react-intl@2.3.0: intl-relativeformat "^1.3.0" invariant "^2.1.1" +react-native-animatable@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/react-native-animatable/-/react-native-animatable-1.2.2.tgz#a873550e6f7cb95f90baf46b9eb15b1ab56425cb" + react-native-bottom-sheet@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/react-native-bottom-sheet/-/react-native-bottom-sheet-1.0.1.tgz#bce4df50cda419f61c938179508640d5d9ff7768"