diff --git a/app/components/channel_loader/index.js b/app/components/channel_loader/index.js index d99f1e453..a4ed5d89d 100644 --- a/app/components/channel_loader/index.js +++ b/app/components/channel_loader/index.js @@ -6,10 +6,10 @@ import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import ChannelLoader from './channel_loader'; -function mapStateToProps(state) { +function mapStateToProps(state, ownProps) { const {deviceWidth} = state.device.dimension; return { - channelIsLoading: state.views.channel.loading, + channelIsLoading: ownProps.channelIsLoading || state.views.channel.loading, deviceWidth, theme: getTheme(state), }; diff --git a/app/components/failed_network_action/cloud.js b/app/components/failed_network_action/cloud.js new file mode 100644 index 000000000..529c87f86 --- /dev/null +++ b/app/components/failed_network_action/cloud.js @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import Svg, {Path} from 'react-native-svg'; + +export default class CloudSvg extends PureComponent { + static propTypes = { + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + color: PropTypes.string.isRequired, + }; + + render() { + const {color, height, width} = this.props; + return ( + + + + + + ); + } +} diff --git a/app/components/failed_network_action/index.js b/app/components/failed_network_action/index.js new file mode 100644 index 000000000..f8165a9bf --- /dev/null +++ b/app/components/failed_network_action/index.js @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {TouchableOpacity, View} from 'react-native'; + +import FormattedText from 'app/components/formatted_text'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import Cloud from './cloud'; + +export default class FailedNetworkAction extends PureComponent { + static propTypes = { + onRetry: PropTypes.func, + theme: PropTypes.object.isRequired, + }; + + render() { + const {theme, onRetry} = this.props; + const style = getStyleFromTheme(theme); + + return ( + + + + + {onRetry && + + + + } + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + paddingHorizontal: 15, + }, + title: { + color: changeOpacity(theme.centerChannelColor, 0.8), + fontSize: 20, + fontWeight: '600', + marginBottom: 15, + }, + description: { + color: changeOpacity(theme.centerChannelColor, 0.4), + fontSize: 17, + textAlign: 'center', + }, + retryContainer: { + marginTop: 30, + }, + retry: { + color: changeOpacity(theme.centerChannelColor, 0.7), + fontSize: 16, + fontWeight: '600', + }, + }; +}); diff --git a/app/components/no_results.js b/app/components/no_results.js new file mode 100644 index 000000000..b255917cc --- /dev/null +++ b/app/components/no_results.js @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {Text, View} from 'react-native'; +import IonIcon from 'react-native-vector-icons/Ionicons'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class NoResults extends PureComponent { + static propTypes = { + description: PropTypes.string, + iconName: PropTypes.string, + theme: PropTypes.object.isRequired, + title: PropTypes.string.isRequired, + }; + + render() { + const { + description, + iconName, + theme, + title, + } = this.props; + const style = getStyleFromTheme(theme); + + let icon; + if (iconName) { + icon = ( + + ); + } + + return ( + + {icon} + {title} + {description && + {description} + } + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + paddingHorizontal: 15, + }, + title: { + color: changeOpacity(theme.centerChannelColor, 0.4), + fontSize: 20, + fontWeight: '600', + marginBottom: 15, + }, + description: { + color: changeOpacity(theme.centerChannelColor, 0.4), + fontSize: 17, + textAlign: 'center', + }, + }; +}); diff --git a/app/components/post_header/post_header.js b/app/components/post_header/post_header.js index 797139f8a..e14cd0eb5 100644 --- a/app/components/post_header/post_header.js +++ b/app/components/post_header/post_header.js @@ -266,7 +266,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - height: 30, + height: 35, minWidth: 40, paddingVertical: 10, }, diff --git a/app/components/post_separator.js b/app/components/post_separator.js new file mode 100644 index 000000000..bd9c7c1a2 --- /dev/null +++ b/app/components/post_separator.js @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +const SEPARATOR_HEIGHT = 3; + +export default class PostSeparator extends PureComponent { + static propTypes = { + theme: PropTypes.object.isRequired, + }; + + render() { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + return ( + + + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + separatorContainer: { + justifyContent: 'center', + flex: 1, + height: SEPARATOR_HEIGHT, + }, + postsSeparator: { + height: 15, + }, + separator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + height: 1, + }, + }; +}); diff --git a/app/components/settings_drawer/settings_drawer.js b/app/components/settings_drawer/settings_drawer.js index f9cce998b..b0401f55c 100644 --- a/app/components/settings_drawer/settings_drawer.js +++ b/app/components/settings_drawer/settings_drawer.js @@ -180,58 +180,45 @@ export default class SettingsDrawer extends PureComponent { }); goToEditProfile = preventDoubleTap(() => { - const {currentUser, navigator, theme} = this.props; + const {currentUser} = this.props; const {formatMessage} = this.context.intl; this.closeSettingsDrawer(); - navigator.showModal({ - screen: 'EditProfile', - title: formatMessage({id: 'mobile.routes.edit_profile', defaultMessage: 'Edit Profile'}), - animationType: 'slide-up', - animated: true, - backButtonTitle: '', - navigatorStyle: { - navBarTextColor: theme.sidebarHeaderTextColor, - navBarBackgroundColor: theme.sidebarHeaderBg, - navBarButtonColor: theme.sidebarHeaderTextColor, - screenBackgroundColor: theme.centerChannelBg, - }, - navigatorButtons: { - leftButtons: [{ - id: 'close-settings', - icon: this.closeButton, - }], - }, - passProps: { - currentUser, - }, - }); + this.openModal( + 'EditProfile', + formatMessage({id: 'mobile.routes.edit_profile', defaultMessage: 'Edit Profile'}), + {currentUser} + ); + }); + + goToFlagged = preventDoubleTap(() => { + const {formatMessage} = this.context.intl; + + this.closeSettingsDrawer(); + this.openModal( + 'FlaggedPosts', + formatMessage({id: 'search_header.title3', defaultMessage: 'Flagged Posts'}), + ); + }); + + goToMentions = preventDoubleTap(() => { + const {intl} = this.context; + + this.closeSettingsDrawer(); + this.openModal( + 'RecentMentions', + intl.formatMessage({id: 'search_header.title2', defaultMessage: 'Recent Mentions'}), + ); }); goToSettings = preventDoubleTap(() => { const {intl} = this.context; - const {navigator, theme} = this.props; this.closeSettingsDrawer(); - navigator.showModal({ - screen: 'Settings', - title: intl.formatMessage({id: 'mobile.routes.settings', defaultMessage: 'Settings'}), - animationType: 'slide-up', - animated: true, - backButtonTitle: '', - navigatorStyle: { - navBarTextColor: theme.sidebarHeaderTextColor, - navBarBackgroundColor: theme.sidebarHeaderBg, - navBarButtonColor: theme.sidebarHeaderTextColor, - screenBackgroundColor: theme.centerChannelBg, - }, - navigatorButtons: { - leftButtons: [{ - id: 'close-settings', - icon: this.closeButton, - }], - }, - }); + this.openModal( + 'Settings', + intl.formatMessage({id: 'mobile.routes.settings', defaultMessage: 'Settings'}), + ); }); logout = preventDoubleTap(() => { @@ -240,6 +227,32 @@ export default class SettingsDrawer extends PureComponent { InteractionManager.runAfterInteractions(logout); }); + openModal = (screen, title, passProps) => { + const {navigator, theme} = this.props; + + this.closeSettingsDrawer(); + navigator.showModal({ + screen, + title, + animationType: 'slide-up', + animated: true, + backButtonTitle: '', + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + navigatorButtons: { + leftButtons: [{ + id: 'close-settings', + icon: this.closeButton, + }], + }, + passProps, + }); + }; + renderUserStatusIcon = (userId) => { return ( + + + + + + + + { + const {actions, navigator, theme} = this.props; + const channelId = post.channel_id; + const rootId = (post.root_id || post.id); + + Keyboard.dismiss(); + actions.loadThreadIfNecessary(rootId, channelId); + actions.selectPost(rootId); + + const options = { + screen: 'Thread', + animated: true, + backButtonTitle: '', + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + passProps: { + channelId, + rootId, + }, + }; + + navigator.push(options); + }; + + handleClosePermalink = () => { + const {actions} = this.props; + actions.selectFocusedPostId(''); + this.showingPermalink = false; + }; + + handlePermalinkPress = (postId, teamName) => { + this.props.actions.loadChannelsByTeamName(teamName); + this.showPermalinkView(postId, true); + }; + + keyExtractor = (item) => item; + + onNavigatorEvent = (event) => { + if (event.type === 'NavBarButtonPress') { + if (event.id === 'close-settings') { + this.props.navigator.dismissModal({ + animationType: 'slide-down', + }); + } + } + }; + + previewPost = (post) => { + Keyboard.dismiss(); + + this.showPermalinkView(post.id, false); + }; + + renderEmpty = () => { + const {formatMessage} = this.context.intl; + const {theme} = this.props; + + return ( + + ); + }; + + renderPost = ({item, index}) => { + const {postIds, theme} = this.props; + const {managedConfig} = this.state; + if (item.indexOf(DATE_LINE) === 0) { + const date = new Date(item.substring(DATE_LINE.length)); + + return ( + + ); + } + + let separator; + const nextPost = postIds[index + 1]; + if (nextPost && nextPost.indexOf(DATE_LINE) === -1) { + separator = ; + } + + return ( + + + + {separator} + + ); + }; + + setManagedConfig = async (config) => { + let nextConfig = config; + if (!nextConfig) { + nextConfig = await mattermostManaged.getLocalConfig(); + } + + this.setState({ + managedConfig: nextConfig, + }); + }; + + showPermalinkView = (postId, isPermalink) => { + const {actions, navigator} = this.props; + + actions.selectFocusedPostId(postId); + + if (!this.showingPermalink) { + const options = { + screen: 'Permalink', + animationType: 'none', + backButtonTitle: '', + overrideBackPress: true, + navigatorStyle: { + navBarHidden: true, + screenBackgroundColor: changeOpacity('#000', 0.2), + modalPresentationStyle: 'overCurrentContext', + }, + passProps: { + isPermalink, + onClose: this.handleClosePermalink, + onPermalinkPress: this.handlePermalinkPress, + }, + }; + + this.showingPermalink = true; + navigator.showModal(options); + } + }; + + retry = () => { + this.props.actions.getFlaggedPosts(); + }; + + render() { + const {didFail, isLoading, postIds, theme} = this.props; + + let component; + if (didFail) { + component = ( + + ); + } else if (isLoading) { + component = ( + + ); + } else if (postIds.length) { + component = ( + + ); + } else { + component = this.renderEmpty(); + } + + return ( + + + + {component} + + + ); + } +} + +const style = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/app/screens/flagged_posts/index.js b/app/screens/flagged_posts/index.js new file mode 100644 index 000000000..1a26d557c --- /dev/null +++ b/app/screens/flagged_posts/index.js @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {selectFocusedPostId, selectPost} from 'mattermost-redux/actions/posts'; +import {clearSearch, getFlaggedPosts} from 'mattermost-redux/actions/search'; +import {RequestStatus} from 'mattermost-redux/constants'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import {loadChannelsByTeamName, loadThreadIfNecessary} from 'app/actions/views/channel'; +import {makePreparePostIdsForSearchPosts} from 'app/selectors/post_list'; + +import FlaggedPosts from './flagged_posts'; + +function makeMapStateToProps() { + const preparePostIds = makePreparePostIdsForSearchPosts(); + return (state) => { + const postIds = preparePostIds(state, state.entities.search.flagged); + const {flaggedPosts: flaggedPostsRequest} = state.requests.search; + const isLoading = flaggedPostsRequest.status === RequestStatus.STARTED || + flaggedPostsRequest.status === RequestStatus.NOT_STARTED; + + return { + postIds, + isLoading, + didFail: flaggedPostsRequest.status === RequestStatus.FAILURE, + theme: getTheme(state), + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + clearSearch, + loadChannelsByTeamName, + loadThreadIfNecessary, + getFlaggedPosts, + selectFocusedPostId, + selectPost, + }, dispatch), + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(FlaggedPosts); diff --git a/app/screens/index.js b/app/screens/index.js index ec68aaf5b..d04315c30 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -21,6 +21,7 @@ import DisplaySettings from 'app/screens/settings/display_settings'; import EditChannel from 'app/screens/edit_channel'; import EditPost from 'app/screens/edit_post'; import EditProfile from 'app/screens/edit_profile'; +import FlaggedPosts from 'app/screens/flagged_posts'; import ImagePreview from 'app/screens/image_preview'; import TextPreview from 'app/screens/text_preview'; import Login from 'app/screens/login'; @@ -37,6 +38,7 @@ import NotificationSettingsMentionsKeywords from 'app/screens/settings/notificat import NotificationSettingsMobile from 'app/screens/settings/notification_settings_mobile'; import OptionsModal from 'app/screens/options_modal'; import Permalink from 'app/screens/permalink'; +import RecentMentions from 'app/screens/recent_mentions'; import Root from 'app/screens/root'; import SSO from 'app/screens/sso'; import Search from 'app/screens/search'; @@ -81,6 +83,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('EditChannel', () => wrapWithContextProvider(EditChannel), store, Provider); Navigation.registerComponent('EditPost', () => wrapWithContextProvider(EditPost), store, Provider); Navigation.registerComponent('EditProfile', () => wrapWithContextProvider(EditProfile), store, Provider); + Navigation.registerComponent('FlaggedPosts', () => wrapWithContextProvider(FlaggedPosts), store, Provider); Navigation.registerComponent('ImagePreview', () => wrapWithContextProvider(ImagePreview), store, Provider); Navigation.registerComponent('TextPreview', () => wrapWithContextProvider(TextPreview), store, Provider); Navigation.registerComponent('Login', () => wrapWithContextProvider(Login), store, Provider); @@ -97,6 +100,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('NotificationSettingsMobile', () => wrapWithContextProvider(NotificationSettingsMobile), store, Provider); Navigation.registerComponent('OptionsModal', () => wrapWithContextProvider(OptionsModal), store, Provider); Navigation.registerComponent('Permalink', () => wrapWithContextProvider(Permalink), store, Provider); + Navigation.registerComponent('RecentMentions', () => wrapWithContextProvider(RecentMentions), store, Provider); Navigation.registerComponent('Root', () => Root, store, Provider); Navigation.registerComponent('Search', () => wrapWithContextProvider(Search), store, Provider); Navigation.registerComponent('SelectServer', () => wrapWithContextProvider(SelectServer), store, Provider); diff --git a/app/screens/recent_mentions/index.js b/app/screens/recent_mentions/index.js new file mode 100644 index 000000000..516742034 --- /dev/null +++ b/app/screens/recent_mentions/index.js @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {selectFocusedPostId, selectPost} from 'mattermost-redux/actions/posts'; +import {clearSearch, getRecentMentions} from 'mattermost-redux/actions/search'; +import {RequestStatus} from 'mattermost-redux/constants'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import {loadChannelsByTeamName, loadThreadIfNecessary} from 'app/actions/views/channel'; +import {makePreparePostIdsForSearchPosts} from 'app/selectors/post_list'; + +import RecentMentions from './recent_mentions'; + +function makeMapStateToProps() { + const preparePostIds = makePreparePostIdsForSearchPosts(); + return (state) => { + const postIds = preparePostIds(state, state.entities.search.results); + const {recentMentions: recentMentionsRequest} = state.requests.search; + const isLoading = recentMentionsRequest.status === RequestStatus.STARTED || + recentMentionsRequest.status === RequestStatus.NOT_STARTED; + + return { + postIds, + isLoading, + didFail: recentMentionsRequest.status === RequestStatus.FAILURE, + theme: getTheme(state), + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + clearSearch, + loadChannelsByTeamName, + loadThreadIfNecessary, + getRecentMentions, + selectFocusedPostId, + selectPost, + }, dispatch), + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(RecentMentions); diff --git a/app/screens/recent_mentions/recent_mentions.js b/app/screens/recent_mentions/recent_mentions.js new file mode 100644 index 000000000..ce945ec10 --- /dev/null +++ b/app/screens/recent_mentions/recent_mentions.js @@ -0,0 +1,276 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; +import { + Keyboard, + FlatList, + SafeAreaView, + StyleSheet, + View, +} from 'react-native'; + +import ChannelLoader from 'app/components/channel_loader'; +import DateHeader from 'app/components/post_list/date_header'; +import FailedNetworkAction from 'app/components/failed_network_action'; +import NoResults from 'app/components/no_results'; +import PostSeparator from 'app/components/post_separator'; +import StatusBar from 'app/components/status_bar'; +import mattermostManaged from 'app/mattermost_managed'; +import SearchResultPost from 'app/screens/search/search_result_post'; +import ChannelDisplayName from 'app/screens/search/channel_display_name'; +import {DATE_LINE} from 'app/selectors/post_list'; +import {changeOpacity} from 'app/utils/theme'; + +export default class RecentMentions extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + clearSearch: PropTypes.func.isRequired, + loadChannelsByTeamName: PropTypes.func.isRequired, + loadThreadIfNecessary: PropTypes.func.isRequired, + getRecentMentions: PropTypes.func.isRequired, + selectFocusedPostId: PropTypes.func.isRequired, + selectPost: PropTypes.func.isRequired, + }).isRequired, + didFail: PropTypes.bool, + isLoading: PropTypes.bool, + navigator: PropTypes.object, + postIds: PropTypes.array, + theme: PropTypes.object.isRequired, + }; + + static defaultProps = { + postIds: [], + }; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props) { + super(props); + + props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); + props.actions.clearSearch(); + props.actions.getRecentMentions(); + + this.state = { + managedConfig: {}, + }; + } + + componentWillMount() { + this.listenerId = mattermostManaged.addEventListener('change', this.setManagedConfig); + } + + componentDidMount() { + this.setManagedConfig(); + } + + componentWillUnmount() { + mattermostManaged.removeEventListener(this.listenerId); + } + + goToThread = (post) => { + const {actions, navigator, theme} = this.props; + const channelId = post.channel_id; + const rootId = (post.root_id || post.id); + + Keyboard.dismiss(); + actions.loadThreadIfNecessary(rootId, channelId); + actions.selectPost(rootId); + + const options = { + screen: 'Thread', + animated: true, + backButtonTitle: '', + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + passProps: { + channelId, + rootId, + }, + }; + + navigator.push(options); + }; + + handleClosePermalink = () => { + const {actions} = this.props; + actions.selectFocusedPostId(''); + this.showingPermalink = false; + }; + + handlePermalinkPress = (postId, teamName) => { + this.props.actions.loadChannelsByTeamName(teamName); + this.showPermalinkView(postId, true); + }; + + keyExtractor = (item) => item; + + onNavigatorEvent = (event) => { + if (event.type === 'NavBarButtonPress') { + if (event.id === 'close-settings') { + this.props.navigator.dismissModal({ + animationType: 'slide-down', + }); + } + } + }; + + previewPost = (post) => { + Keyboard.dismiss(); + + this.showPermalinkView(post.id, false); + }; + + renderEmpty = () => { + const {formatMessage} = this.context.intl; + const {theme} = this.props; + + return ( + + ); + }; + + renderPost = ({item, index}) => { + const {postIds, theme} = this.props; + const {managedConfig} = this.state; + if (item.indexOf(DATE_LINE) === 0) { + const date = new Date(item.substring(DATE_LINE.length)); + + return ( + + ); + } + + let separator; + const nextPost = postIds[index + 1]; + if (nextPost && nextPost.indexOf(DATE_LINE) === -1) { + separator = ; + } + + return ( + + + + {separator} + + ); + }; + + setManagedConfig = async (config) => { + let nextConfig = config; + if (!nextConfig) { + nextConfig = await mattermostManaged.getLocalConfig(); + } + + this.setState({ + managedConfig: nextConfig, + }); + }; + + showPermalinkView = (postId, isPermalink) => { + const {actions, navigator} = this.props; + + actions.selectFocusedPostId(postId); + + if (!this.showingPermalink) { + const options = { + screen: 'Permalink', + animationType: 'none', + backButtonTitle: '', + overrideBackPress: true, + navigatorStyle: { + navBarHidden: true, + screenBackgroundColor: changeOpacity('#000', 0.2), + modalPresentationStyle: 'overCurrentContext', + }, + passProps: { + isPermalink, + onClose: this.handleClosePermalink, + onPermalinkPress: this.handlePermalinkPress, + }, + }; + + this.showingPermalink = true; + navigator.showModal(options); + } + }; + + retry = () => { + this.props.actions.getRecentMentions(); + }; + + render() { + const {didFail, isLoading, postIds, theme} = this.props; + + let component; + if (didFail) { + component = ( + + ); + } else if (isLoading) { + component = ( + + ); + } else if (postIds.length) { + component = ( + + ); + } else { + component = this.renderEmpty(); + } + + return ( + + + + {component} + + + ); + } +} + +const style = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/app/screens/search/index.js b/app/screens/search/index.js index d96ea926c..2fbc724bc 100644 --- a/app/screens/search/index.js +++ b/app/screens/search/index.js @@ -12,24 +12,30 @@ import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {loadChannelsByTeamName, loadThreadIfNecessary} from 'app/actions/views/channel'; import {isLandscape} from 'app/selectors/device'; +import {makePreparePostIdsForSearchPosts} from 'app/selectors/post_list'; import {handleSearchDraftChanged} from 'app/actions/views/search'; import Search from './search'; -function mapStateToProps(state) { - const currentTeamId = getCurrentTeamId(state); - const currentChannelId = getCurrentChannelId(state); - const {recent} = state.entities.search; - const {searchPosts: searchRequest} = state.requests.search; +function makeMapStateToProps() { + const preparePostIds = makePreparePostIdsForSearchPosts(); - return { - currentTeamId, - currentChannelId, - isLandscape: isLandscape(state), - postIds: state.entities.search.results, - recent: recent[currentTeamId], - searchingStatus: searchRequest.status, - theme: getTheme(state), + return (state) => { + const postIds = preparePostIds(state, state.entities.search.results); + const currentTeamId = getCurrentTeamId(state); + const currentChannelId = getCurrentChannelId(state); + const {recent} = state.entities.search; + const {searchPosts: searchRequest} = state.requests.search; + + return { + currentTeamId, + currentChannelId, + isLandscape: isLandscape(state), + postIds, + recent: recent[currentTeamId], + searchingStatus: searchRequest.status, + theme: getTheme(state), + }; }; } @@ -48,4 +54,4 @@ function mapDispatchToProps(dispatch) { }; } -export default connect(mapStateToProps, mapDispatchToProps)(Search); +export default connect(makeMapStateToProps, mapDispatchToProps)(Search); diff --git a/app/screens/search/search.js b/app/screens/search/search.js index 875170376..f20717a42 100644 --- a/app/screens/search/search.js +++ b/app/screens/search/search.js @@ -20,6 +20,7 @@ import AwesomeIcon from 'react-native-vector-icons/FontAwesome'; import {RequestStatus} from 'mattermost-redux/constants'; import Autocomplete from 'app/components/autocomplete'; +import DateHeader from 'app/components/post_list/date_header'; import FormattedText from 'app/components/formatted_text'; import Loading from 'app/components/loading'; import PostListRetry from 'app/components/post_list_retry'; @@ -27,6 +28,7 @@ import SafeAreaView from 'app/components/safe_area_view'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; import mattermostManaged from 'app/mattermost_managed'; +import {DATE_LINE} from 'app/selectors/post_list'; import {preventDoubleTap} from 'app/utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; @@ -259,6 +261,15 @@ export default class Search extends PureComponent { actions.removeSearchTerms(currentTeamId, item.terms); }); + renderDateHeader = (date, index) => { + return ( + + ); + }; + renderModifiers = ({item}) => { const {theme} = this.props; const style = getStyleFromTheme(theme); @@ -306,8 +317,14 @@ export default class Search extends PureComponent { ); } + if (item.indexOf(DATE_LINE) === 0) { + const date = item.substring(DATE_LINE.length); + return this.renderDateHeader(new Date(date), index); + } + let separator; - if (index === postIds.length - 1) { + const nextPost = postIds[index + 1]; + if (nextPost && nextPost.indexOf(DATE_LINE) === -1) { separator = this.renderPostSeparator(); } @@ -569,7 +586,6 @@ export default class Search extends PureComponent { title: intl.formatMessage({id: 'search_header.results', defaultMessage: 'Search Results'}), renderItem: this.renderPost, keyExtractor: this.keyPostExtractor, - ItemSeparatorComponent: this.renderPostSeparator, }); } diff --git a/app/screens/search/search_result_post/search_result_post.js b/app/screens/search/search_result_post/search_result_post.js index d934fcd87..93a5c86fa 100644 --- a/app/screens/search/search_result_post/search_result_post.js +++ b/app/screens/search/search_result_post/search_result_post.js @@ -15,6 +15,11 @@ export default class SearchResultPost extends PureComponent { onPermalinkPress: PropTypes.func.isRequired, postId: PropTypes.string.isRequired, previewPost: PropTypes.func.isRequired, + showFullDate: PropTypes.bool, + }; + + static defaultProps = { + showFullDate: false, }; render() { @@ -35,7 +40,7 @@ export default class SearchResultPost extends PureComponent { postId={this.props.postId} {...postComponentProps} isSearchResult={true} - showFullDate={true} + showFullDate={this.props.showFullDate} navigator={this.props.navigator} /> ); diff --git a/app/selectors/post_list.js b/app/selectors/post_list.js index 002118e88..33c9ddc4a 100644 --- a/app/selectors/post_list.js +++ b/app/selectors/post_list.js @@ -80,3 +80,44 @@ export function makePreparePostIdsForPostList() { } ); } + +export function makePreparePostIdsForSearchPosts() { + const getMyPosts = makeGetPostsForIds(); + + return createIdsSelector( + (state, postIds) => getMyPosts(state, postIds), + getCurrentUser, + (posts, currentUser) => { + if (posts.length === 0 || !currentUser) { + return []; + } + + const out = []; + let lastDate = null; + for (let i = 0; i < posts.length; i++) { + const post = posts[i]; + + // give chance for the post to be loaded + if (!post) { + continue; + } + + if (post.state === Posts.POST_DELETED && post.user_id === currentUser.id) { + continue; + } + + // Push on a date header if the last post was on a different day than the current one + const postDate = new Date(post.create_at); + + if (!lastDate || lastDate.toDateString() !== postDate.toDateString()) { + out.push(DATE_LINE + postDate.toString()); + lastDate = postDate; + } + out.push(post.id); + } + + // Flip it back to newest to oldest + return out; + } + ); +} diff --git a/app/store/middleware.js b/app/store/middleware.js index ccd7e7fa8..204383dd6 100644 --- a/app/store/middleware.js +++ b/app/store/middleware.js @@ -224,10 +224,19 @@ function cleanupState(action, keepCurrent = false) { }, []); let searchResults = []; - if (payload.entities.search && payload.entities.search.results.length) { - const {results} = payload.entities.search; - searchResults = results; - postIdsToKeep.push(...results); + let flaggedPosts = []; + if (payload.entities.search) { + if (payload.entities.search.results.length) { + const {results} = payload.entities.search; + searchResults = results; + postIdsToKeep.push(...results); + } + + if (payload.entities.search.flagged.length) { + const {flagged} = payload.entities.search; + flaggedPosts = flagged; + postIdsToKeep.push(...flagged); + } } postIdsToKeep.forEach((postId) => { @@ -314,6 +323,7 @@ function cleanupState(action, keepCurrent = false) { search: { ...resetPayload.entities.search, results: searchResults, + flagged: flaggedPosts, }, teams: resetPayload.entities.teams, users: payload.entities.users, diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 1a36ba8e3..3f2850958 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -2108,11 +2108,16 @@ "mobile.extension.permission": "Mattermost needs access to the device storage to share files.", "mobile.extension.posting": "Posting...", "mobile.extension.title": "Share in Mattermost", + "mobile.failed_network_action.description": "There seems to be a problem with your internet connection. Make sure you have an active connection and try again.", + "mobile.failed_network_action.title": "No internet connection", + "mobile.failed_network_action.retry": "Try Again", "mobile.file_upload.camera": "Take Photo or Video", "mobile.file_upload.library": "Photo Library", "mobile.file_upload.max_warning": "Uploads limited to 5 files maximum.", "mobile.file_upload.more": "More", "mobile.file_upload.video": "Video Library", + "mobile.flagged_posts.empty_title": "No Flagged Posts", + "mobile.flagged_posts.empty_description": "Flags are a way to mark messages for follow up. Your flags are personal, and cannot be seen by other users.", "mobile.help.title": "Help", "mobile.image_preview.deleted_post_message": "This post and its files have been deleted. The previewer will now be closed.", "mobile.image_preview.deleted_post_title": "Post Deleted", @@ -2199,6 +2204,8 @@ "mobile.post_textbox.uploadFailedDesc": "Some attachments failed to upload to the server, Are you sure you want to post the message?", "mobile.post_textbox.uploadFailedTitle": "Attachment failure", "mobile.posts_view.moreMsg": "More New Messages Above", + "mobile.recent_mentions.empty_title": "No Recent Mentions", + "mobile.recent_mentions.empty_description": "Messages containing your username and other words that trigger mentions will appear here.", "mobile.rename_channel.display_name_maxLength": "Channel name must be less than {maxLength, number} characters", "mobile.rename_channel.display_name_minLength": "Channel name must be {minLength, number} or more characters", "mobile.rename_channel.display_name_required": "Channel name is required", diff --git a/yarn.lock b/yarn.lock index dc4d57bf6..d38e8e142 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2175,7 +2175,7 @@ commonmark-react-renderer@mattermost/commonmark-react-renderer: pascalcase "^0.1.1" xss-filters "^1.2.6" -"commonmark@mattermost/commonmark.js": +commonmark@mattermost/commonmark.js: version "0.28.0" resolved "https://codeload.github.com/mattermost/commonmark.js/tar.gz/1496b5d11f245e00aae51f8fa1b2c4f12b4ddd7b" dependencies: @@ -3655,10 +3655,16 @@ iconv-lite@0.4.13: version "0.4.13" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" -iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@~0.4.13: +iconv-lite@0.4.19, iconv-lite@^0.4.17: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" +iconv-lite@~0.4.13: + version "0.4.21" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.21.tgz#c47f8733d02171189ebc4a400f3218d348094798" + dependencies: + safer-buffer "^2.1.0" + ignore@^3.3.3: version "3.3.5" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.5.tgz#c4e715455f6073a8d7e5dae72d2fc9d71663dba6" @@ -4637,7 +4643,7 @@ map-visit@^1.0.0: mattermost-redux@mattermost/mattermost-redux: version "1.2.0" - resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/10d691f386e1dbb7669ae549bbe81b855ef4c505" + resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/3d058a0d98095885e4712ad6c0aadf7e3913ad1c" dependencies: deep-equal "1.0.1" form-data "2.3.1" @@ -6702,6 +6708,10 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" +safer-buffer@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + sane@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sane/-/sane-2.0.0.tgz#99cb79f21f4a53a69d4d0cd957c2db04024b8eb2"