diff --git a/app/components/autocomplete/autocomplete.js b/app/components/autocomplete/autocomplete.js index 7daf48e53..24776a66e 100644 --- a/app/components/autocomplete/autocomplete.js +++ b/app/components/autocomplete/autocomplete.js @@ -16,6 +16,7 @@ import AtMention from './at_mention'; import ChannelMention from './channel_mention'; import EmojiSuggestion from './emoji_suggestion'; import SlashSuggestion from './slash_suggestion'; +import DateSuggestion from './date_suggestion'; export default class Autocomplete extends PureComponent { static propTypes = { @@ -26,11 +27,13 @@ export default class Autocomplete extends PureComponent { isSearch: PropTypes.bool, theme: PropTypes.object.isRequired, value: PropTypes.string, + enableDateSuggestion: PropTypes.bool.isRequired, }; static defaultProps = { isSearch: false, cursorPosition: 0, + enableDateSuggestion: false, }; state = { @@ -38,6 +41,7 @@ export default class Autocomplete extends PureComponent { channelMentionCount: 0, emojiCount: 0, commandCount: 0, + dateCount: 0, keyboardOffset: 0, }; @@ -57,6 +61,10 @@ export default class Autocomplete extends PureComponent { this.setState({commandCount}); }; + handleIsDateFilterChange = (dateCount) => { + this.setState({dateCount}); + }; + componentWillMount() { this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this.keyboardDidShow); this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this.keyboardDidHide); @@ -97,8 +105,8 @@ export default class Autocomplete extends PureComponent { } // We always need to render something, but we only draw the borders when we have results to show - const {atMentionCount, channelMentionCount, emojiCount, commandCount} = this.state; - if (atMentionCount + channelMentionCount + emojiCount + commandCount > 0) { + const {atMentionCount, channelMentionCount, emojiCount, commandCount, dateCount} = this.state; + if (atMentionCount + channelMentionCount + emojiCount + commandCount + dateCount > 0) { if (this.props.isSearch) { wrapperStyle.push(style.bordersSearch); } else { @@ -128,6 +136,12 @@ export default class Autocomplete extends PureComponent { onResultCountChange={this.handleCommandCountChange} {...this.props} /> + {(this.props.isSearch && this.props.enableDateSuggestion) && + + } ); diff --git a/app/components/autocomplete/date_suggestion/date_suggestion.js b/app/components/autocomplete/date_suggestion/date_suggestion.js new file mode 100644 index 000000000..acd8101fa --- /dev/null +++ b/app/components/autocomplete/date_suggestion/date_suggestion.js @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {Keyboard, StyleSheet} from 'react-native'; +import PropTypes from 'prop-types'; +import {CalendarList} from 'react-native-calendars'; + +import {DATE_MENTION_SEARCH_REGEX, ALL_SEARCH_FLAGS_REGEX} from 'app/constants/autocomplete'; + +export default class DateSuggestion extends PureComponent { + static propTypes = { + cursorPosition: PropTypes.number.isRequired, + listHeight: PropTypes.number, + matchTerm: PropTypes.string, + onChangeText: PropTypes.func.isRequired, + onResultCountChange: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + value: PropTypes.string, + enableDateSuggestion: PropTypes.bool.isRequired, + }; + + static defaultProps = { + value: '', + }; + + constructor(props) { + super(props); + + this.state = { + mentionComplete: false, + sections: [], + }; + } + + componentWillReceiveProps(nextProps) { + const {matchTerm} = nextProps; + + if ((matchTerm !== this.props.matchTerm && matchTerm === null) || this.state.mentionComplete) { + // if the term changes but is null or the mention has been completed we render this component as null + this.setState({ + mentionComplete: false, + sections: [], + }); + + this.props.onResultCountChange(0); + } + } + + completeMention = (day) => { + const mention = day.dateString; + const {cursorPosition, onChangeText, value} = this.props; + const mentionPart = value.substring(0, cursorPosition); + const flags = mentionPart.match(ALL_SEARCH_FLAGS_REGEX); + const currentFlag = flags[flags.length - 1]; + let completedDraft = mentionPart.replace(DATE_MENTION_SEARCH_REGEX, `${currentFlag} ${mention} `); + + if (value.length > cursorPosition) { + completedDraft += value.substring(cursorPosition); + } + + onChangeText(completedDraft, true); + this.props.onResultCountChange(1); + this.setState({mentionComplete: true}); + }; + + render() { + const {mentionComplete} = this.state; + const {matchTerm, enableDateSuggestion} = this.props; + + if (matchTerm === null || mentionComplete || !enableDateSuggestion) { + // If we are not in an active state or the mention has been completed return null so nothing is rendered + // other components are not blocked. + return null; + } + + const currentDate = (new Date()).toDateString(); + + Keyboard.dismiss(); + + return ( + + ); + } +} + +const styles = StyleSheet.create({ + calList: { + height: 1700, + }, +}); diff --git a/app/components/autocomplete/date_suggestion/index.js b/app/components/autocomplete/date_suggestion/index.js new file mode 100644 index 000000000..1e3a28453 --- /dev/null +++ b/app/components/autocomplete/date_suggestion/index.js @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {makeGetMatchTermForDateMention} from 'app/selectors/autocomplete'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import DateSuggestion from './date_suggestion'; + +function makeMapStateToProps() { + const getMatchTermForDateMention = makeGetMatchTermForDateMention(); + + return (state, ownProps) => { + const {cursorPosition, value} = ownProps; + + const newValue = value.substring(0, cursorPosition); + const matchTerm = getMatchTermForDateMention(newValue); + + return { + matchTerm, + theme: getTheme(state), + }; + }; +} + +export default connect(makeMapStateToProps)(DateSuggestion); diff --git a/app/constants/autocomplete.js b/app/constants/autocomplete.js index 220a269cb..4f9c13f0c 100644 --- a/app/constants/autocomplete.js +++ b/app/constants/autocomplete.js @@ -8,3 +8,7 @@ export const AT_MENTION_SEARCH_REGEX = /\bfrom:\s*(\S*)$/i; export const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i; export const CHANNEL_MENTION_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i; + +export const DATE_MENTION_SEARCH_REGEX = /\b(?:on|before|after):\s*(\S*)$/i; + +export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g; \ No newline at end of file diff --git a/app/screens/search/index.js b/app/screens/search/index.js index 46e4727b9..fd0a060b1 100644 --- a/app/screens/search/index.js +++ b/app/screens/search/index.js @@ -5,10 +5,11 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {selectFocusedPostId, selectPost} from 'mattermost-redux/actions/posts'; -import {clearSearch, removeSearchTerms, searchPosts} from 'mattermost-redux/actions/search'; +import {clearSearch, removeSearchTerms, searchPostsWithParams} from 'mattermost-redux/actions/search'; import {getCurrentChannelId, filterPostIds} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers'; import {loadChannelsByTeamName, loadThreadIfNecessary} from 'app/actions/views/channel'; import {isLandscape} from 'app/selectors/device'; @@ -29,6 +30,9 @@ function makeMapStateToProps() { const {recent} = state.entities.search; const {searchPosts: searchRequest} = state.requests.search; + const serverVersion = state.entities.general.serverVersion; + const enableDateSuggestion = isMinimumServerVersion(serverVersion, 5, 3); + return { currentTeamId, currentChannelId, @@ -38,6 +42,7 @@ function makeMapStateToProps() { recent: recent[currentTeamId], searchingStatus: searchRequest.status, theme: getTheme(state), + enableDateSuggestion, }; }; } @@ -51,7 +56,7 @@ function mapDispatchToProps(dispatch) { loadThreadIfNecessary, removeSearchTerms, selectFocusedPostId, - searchPosts, + searchPostsWithParams, selectPost, }, dispatch), }; diff --git a/app/screens/search/search.js b/app/screens/search/search.js index c398e120a..58929854f 100644 --- a/app/screens/search/search.js +++ b/app/screens/search/search.js @@ -31,6 +31,7 @@ import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; import mattermostManaged from 'app/mattermost_managed'; import {preventDoubleTap} from 'app/utils/tap'; +import {getDeviceUtcOffset} from 'app/utils/timezone'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import ChannelDisplayName from './channel_display_name'; @@ -51,7 +52,7 @@ export default class Search extends PureComponent { loadChannelsByTeamName: PropTypes.func.isRequired, loadThreadIfNecessary: PropTypes.func.isRequired, removeSearchTerms: PropTypes.func.isRequired, - searchPosts: PropTypes.func.isRequired, + searchPostsWithParams: PropTypes.func.isRequired, selectFocusedPostId: PropTypes.func.isRequired, selectPost: PropTypes.func.isRequired, }).isRequired, @@ -63,6 +64,7 @@ export default class Search extends PureComponent { recent: PropTypes.array.isRequired, searchingStatus: PropTypes.string, theme: PropTypes.object.isRequired, + enableDateSuggestion: PropTypes.bool, }; static defaultProps = { @@ -466,7 +468,9 @@ export default class Search extends PureComponent { }, }); - actions.searchPosts(currentTeamId, terms.trim(), isOrSearch, true); + // timezone offset in seconds + const timeZoneOffset = getDeviceUtcOffset() * 60; + actions.searchPostsWithParams(currentTeamId, {terms: terms.trim(), is_or_search: isOrSearch, time_zone_offset: timeZoneOffset}, true); }; handleSearchButtonPress = preventDoubleTap((text) => { @@ -514,22 +518,53 @@ export default class Search extends PureComponent { value, } = this.state; const style = getStyleFromTheme(theme); + + const sectionsData = [{ + value: 'from:', + modifier: `from:${intl.formatMessage({id: 'mobile.search.from_modifier_title', defaultMessage: 'username'})}`, + description: intl.formatMessage({ + id: 'mobile.search.from_modifier_description', + defaultMessage: 'to find posts from specific users', + }), + }, { + value: 'in:', + modifier: `in:${intl.formatMessage({id: 'mobile.search.in_modifier_title', defaultMessage: 'channel-name'})}`, + description: intl.formatMessage({ + id: 'mobile.search.in_modifier_description', + defaultMessage: 'to find posts in specific channels', + }), + }]; + + // if search by date filters supported + if (this.props.enableDateSuggestion) { + sectionsData.push({ + value: 'on:', + modifier: 'on: YYYY-MM-DD', + description: intl.formatMessage({ + id: 'mobile.search.on_modifier_description', + defaultMessage: 'to find posts on a specific date', + }), + }); + sectionsData.push({ + value: 'after:', + modifier: 'after: YYYY-MM-DD', + description: intl.formatMessage({ + id: 'mobile.search.after_modifier_description', + defaultMessage: 'to find posts after a specific date', + }), + }); + sectionsData.push({ + value: 'before:', + modifier: 'before: YYYY-MM-DD', + description: intl.formatMessage({ + id: 'mobile.search.before_modifier_description', + defaultMessage: 'to find posts before a specific date', + }), + }); + } + const sections = [{ - data: [{ - value: 'from:', - modifier: `from:${intl.formatMessage({id: 'mobile.search.from_modifier_title', defaultMessage: 'username'})}`, - description: intl.formatMessage({ - id: 'mobile.search.from_modifier_description', - defaultMessage: 'to find posts from specific users', - }), - }, { - value: 'in:', - modifier: `in:${intl.formatMessage({id: 'mobile.search.in_modifier_title', defaultMessage: 'channel-name'})}`, - description: intl.formatMessage({ - id: 'mobile.search.in_modifier_description', - defaultMessage: 'to find posts in specific channels', - }), - }], + data: sectionsData, key: 'modifiers', title: '', renderItem: this.renderModifiers, @@ -651,6 +686,7 @@ export default class Search extends PureComponent { onChangeText={this.handleTextChanged} isSearch={true} value={value} + enableDateSuggestion={this.props.enableDateSuggestion} /> diff --git a/app/selectors/autocomplete.js b/app/selectors/autocomplete.js index 11e9d20df..8bc92d477 100644 --- a/app/selectors/autocomplete.js +++ b/app/selectors/autocomplete.js @@ -224,3 +224,21 @@ export const filterPrivateChannels = createSelector( return channels.map((c) => c.id); } ); + +export const makeGetMatchTermForDateMention = () => { + let lastMatchTerm = null; + let lastValue; + return (value) => { + if (value !== lastValue) { + const regex = Autocomplete.DATE_MENTION_SEARCH_REGEX; + const match = value.match(regex); + lastValue = value; + if (match) { + lastMatchTerm = match[1]; + } else { + lastMatchTerm = null; + } + } + return lastMatchTerm; + }; +}; diff --git a/app/utils/timezone.js b/app/utils/timezone.js index df8da9520..8498efecf 100644 --- a/app/utils/timezone.js +++ b/app/utils/timezone.js @@ -9,6 +9,11 @@ export function getDeviceTimezone() { return DeviceInfo.getTimezone(); } +export function getDeviceUtcOffset() { + const reverseOffsetInMinutes = new Date().getTimezoneOffset(); + return -reverseOffsetInMinutes; +} + export function isTimezoneEnabled(state) { const {config} = state.entities.general; const serverVersion = state.entities.general.serverVersion; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 9312a9d73..9dd8a4ee3 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -2598,6 +2598,12 @@ "mobile.search.from_modifier_title": "username", "mobile.search.in_modifier_description": "to find posts in specific channels", "mobile.search.in_modifier_title": "channel-name", + "mobile.search.on_modifier_description": "to find posts on a specific date", + "mobile.search.on_modifier_title": "YYYY-MM-DD", + "mobile.search.after_modifier_description": "to find posts after a specific date", + "mobile.search.after_modifier_title": "YYYY-MM-DD", + "mobile.search.before_modifier_description": "to find posts before a specific date", + "mobile.search.before_modifier_title": "YYYY-MM-DD", "mobile.search.jump": "Jump to recent messages", "mobile.search.no_results": "No Results Found", "mobile.search.recent_title": "Recent Searches", diff --git a/package-lock.json b/package-lock.json index bf43cb80b..fa5f64bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2750,6 +2750,46 @@ "integrity": "sha512-NOLEgsT6UiDTjnWG5Hd2Mg25LRyz/oe8ql3wbjzgSFeRzRROhPmtlsvIrei4B46UjERF0td9SZ1ZXPLOdcrBHg==", "dev": true }, + "@types/prop-types": { + "version": "15.5.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.5.tgz", + "integrity": "sha512-mOrlCEdwX3seT3n0AXNt4KNPAZZxcsABUHwBgFXOt+nvFUXkxCAO6UBJHPrDxWEa2KDMil86355fjo8jbZ+K0Q==", + "requires": { + "@types/react": "*" + } + }, + "@types/react": { + "version": "16.4.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.4.11.tgz", + "integrity": "sha512-1DQnmwO8u8N3ucvRX2ZLDEjQ2VctkAvL/rpbm2ev4uaZA0z4ysU+I0tk+K8ZLblC6p7MCgFyF+cQlSNIPUHzeQ==", + "requires": { + "@types/prop-types": "*", + "csstype": "^2.2.0" + } + }, + "@types/react-native": { + "version": "0.56.10", + "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.56.10.tgz", + "integrity": "sha512-xbut9LMHg773sJX+0PJuGoG7+sB7VL24djp/41rrwht9Kg+3TPcqTdP4k3V6Gqi+Jpv/YbmL0Wlxdlu+vVxORA==", + "requires": { + "@types/react": "*" + } + }, + "@types/react-native-calendars": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/react-native-calendars/-/react-native-calendars-1.20.2.tgz", + "integrity": "sha512-mb7H4yRug06STg40SnqGM9c6kvAXcPuImvQjKfr8v8DVrgFpTS0RO2Ykyk+B1s3bpsK3eop8ExLLKmMM4bz5fA==", + "requires": { + "@types/react": "*", + "@types/react-native": "*", + "@types/xdate": "*" + } + }, + "@types/xdate": { + "version": "0.8.29", + "resolved": "https://registry.npmjs.org/@types/xdate/-/xdate-0.8.29.tgz", + "integrity": "sha512-vhjumeX678c1aA5TDdxeLdWjqazQ4qDvgK57pRr5l3Y0H5+ZZYckNtLkJiW4tJ3YVZq43ZFZFVResfAM4UMvTw==" + }, "abab": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", @@ -5413,6 +5453,11 @@ "cssom": "0.3.x" } }, + "csstype": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.5.6.tgz", + "integrity": "sha512-tKPyhy0FmfYD2KQYXD5GzkvAYLYj96cMLXr648CKGd3wBe0QqoPipImjGiLze9c8leJK8J3n7ap90tpk3E6HGQ==" + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -9993,6 +10038,11 @@ "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=" }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -10128,8 +10178,8 @@ } }, "mattermost-redux": { - "version": "github:mattermost/mattermost-redux#11902b0d530e5b3303e7fffe74ef8102d25a1893", - "from": "github:mattermost/mattermost-redux#11902b0d530e5b3303e7fffe74ef8102d25a1893", + "version": "github:mattermost/mattermost-redux#45ae4849c2810b56cb5a420fb7d49d2621145969", + "from": "github:mattermost/mattermost-redux#45ae4849c2810b56cb5a420fb7d49d2621145969", "requires": { "deep-equal": "1.0.1", "eslint-plugin-header": "1.2.0", @@ -14654,6 +14704,17 @@ "prop-types": "^15.5.10" } }, + "react-native-calendars": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/react-native-calendars/-/react-native-calendars-1.20.0.tgz", + "integrity": "sha512-VlRoDcnEAWYE1JBPBh/Bie6baLQCmtuOGhw7V5yk09Y4j7Hy8BtuZIHh2+LU/TFYso+wEHJAFdj6D0QFttDOlg==", + "requires": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "prop-types": "^15.5.10", + "xdate": "^0.8.0" + } + }, "react-native-circular-progress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/react-native-circular-progress/-/react-native-circular-progress-0.2.0.tgz", @@ -18284,6 +18345,11 @@ } } }, + "xdate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/xdate/-/xdate-0.8.2.tgz", + "integrity": "sha1-17AzwASF0CaVuvAET06s2j/JYaM=" + }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index d68688d6e..f7b8ff064 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "license": "Apache 2.0", "private": true, "dependencies": { + "@types/react-native-calendars": "1.20.2", "analytics-react-native": "1.2.0", "commonmark": "github:mattermost/commonmark.js#e5c34706cafa2924370772ff47937b833648a6de", "commonmark-react-renderer": "mattermost/commonmark-react-renderer#86fa63f898802953842526c2030f3b63c5d1ae7a", @@ -15,7 +16,7 @@ "intl": "1.2.5", "jail-monkey": "1.0.0", "jsc-android": "216113.0.3", - "mattermost-redux": "github:mattermost/mattermost-redux#11902b0d530e5b3303e7fffe74ef8102d25a1893", + "mattermost-redux": "github:mattermost/mattermost-redux#45ae4849c2810b56cb5a420fb7d49d2621145969", "mime-db": "1.33.0", "prop-types": "15.6.1", "react": "16.3.2", @@ -24,6 +25,7 @@ "react-native-animatable": "1.2.4", "react-native-bottom-sheet": "1.0.3", "react-native-button": "2.3.0", + "react-native-calendars": "1.20.0", "react-native-circular-progress": "0.2.0", "react-native-cookies": "3.2.0", "react-native-device-info": "enahum/react-native-device-info.git#a7bb3cff1086780b2c791a3e43e5b826fdf3ab11",