diff --git a/app/components/no_results.js b/app/components/no_results.js index a165c1410..c8699231e 100644 --- a/app/components/no_results.js +++ b/app/components/no_results.js @@ -3,7 +3,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Text, View} from 'react-native'; +import {Image, Text, View} from 'react-native'; import IonIcon from 'react-native-vector-icons/Ionicons'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; @@ -12,6 +12,7 @@ export default class NoResults extends PureComponent { static propTypes = { description: PropTypes.string, iconName: PropTypes.string, + image: PropTypes.number, theme: PropTypes.object.isRequired, title: PropTypes.string.isRequired, }; @@ -20,16 +21,24 @@ export default class NoResults extends PureComponent { const { description, iconName, + image, theme, title, } = this.props; const style = getStyleFromTheme(theme); let icon; - if (iconName) { + if (image) { + icon = ( + + ); + } else if (iconName) { icon = ( @@ -60,7 +69,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { color: changeOpacity(theme.centerChannelColor, 0.4), fontSize: 20, fontWeight: '600', - marginBottom: 15, + marginVertical: 15, }, description: { color: changeOpacity(theme.centerChannelColor, 0.4), diff --git a/app/components/post_list/post_list_base.js b/app/components/post_list/post_list_base.js index 119392de3..3c51c4188 100644 --- a/app/components/post_list/post_list_base.js +++ b/app/components/post_list/post_list_base.js @@ -37,7 +37,7 @@ export default class PostListBase extends PureComponent { onPostPress: PropTypes.func, onRefresh: PropTypes.func, postIds: PropTypes.array.isRequired, - refreshing: PropTypes.bool.isRequired, + refreshing: PropTypes.bool, renderFooter: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), renderReplies: PropTypes.bool, serverURL: PropTypes.string.isRequired, diff --git a/app/components/slide_up_panel/slide_up_panel.js b/app/components/slide_up_panel/slide_up_panel.js index db2ac5a9a..270a028ac 100644 --- a/app/components/slide_up_panel/slide_up_panel.js +++ b/app/components/slide_up_panel/slide_up_panel.js @@ -23,6 +23,7 @@ const CONTAINER_MARGIN = TOP_MARGIN - 10; export default class SlideUpPanel extends PureComponent { static propTypes = { + allowStayMiddle: PropTypes.bool, alwaysCaptureContainerMove: PropTypes.bool, containerHeight: PropTypes.number, children: PropTypes.oneOfType([ @@ -36,6 +37,7 @@ export default class SlideUpPanel extends PureComponent { }; static defaultProps = { + allowStayMiddle: true, headerHeight: 0, initialPosition: 0.5, marginFromTop: TOP_MARGIN, @@ -156,14 +158,14 @@ export default class SlideUpPanel extends PureComponent { }; startAnimation = (initialY, positionY, isGoingDown, initial = false) => { - const {containerHeight, onRequestClose} = this.props; + const {allowStayMiddle, containerHeight, onRequestClose} = this.props; const {finalPosition, initialPosition} = this.state; const position = new Animated.Value(initial ? initialY : positionY); let endPosition = (!isGoingDown && !initial ? finalPosition : positionY); position.removeAllListeners(); if (isGoingDown) { - if (positionY <= this.state.initialPosition) { + if (positionY <= this.state.initialPosition && allowStayMiddle) { endPosition = initialPosition; } else { endPosition = containerHeight; diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js index 76ba03382..e94933882 100644 --- a/app/screens/channel_info/channel_info.js +++ b/app/screens/channel_info/channel_info.js @@ -11,14 +11,15 @@ import { View, } from 'react-native'; +import {General} from 'mattermost-redux/constants'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + import StatusBar from 'app/components/status_bar'; import {preventDoubleTap} from 'app/utils/tap'; import {alertErrorWithFallback} from 'app/utils/general'; import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; import {t} from 'app/utils/i18n'; - -import {General} from 'mattermost-redux/constants'; -import EventEmitter from 'mattermost-redux/utils/event_emitter'; +import pinIcon from 'assets/images/channel_info/pin.png'; import ChannelInfoHeader from './channel_info_header'; import ChannelInfoRow from './channel_info_row'; @@ -141,6 +142,29 @@ export default class ChannelInfo extends PureComponent { }); }); + goToPinnedPosts = preventDoubleTap(() => { + const {formatMessage} = this.context.intl; + const {currentChannel, navigator, theme} = this.props; + const id = t('channel_header.pinnedPosts'); + const defaultMessage = 'Pinned Posts'; + + navigator.push({ + backButtonTitle: '', + screen: 'PinnedPosts', + title: formatMessage({id, defaultMessage}), + animated: true, + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + passProps: { + currentChannelId: currentChannel.id, + }, + }); + }); + handleChannelEdit = preventDoubleTap(() => { const {intl} = this.context; const {navigator, theme} = this.props; @@ -363,41 +387,50 @@ export default class ChannelInfo extends PureComponent { ); } - return ( - - - - { + return ( + + + + + + + { - /** - true} - defaultMessage='Notification Preferences' - icon='bell-o' - textId='channel_header.notificationPreferences' - theme={theme} - /> - - **/ - } - {this.renderViewOrManageMembersRow() && - + /** + true} + defaultMessage='Notification Preferences' + icon='bell-o' + textId='channel_header.notificationPreferences' + theme={theme} + /> + + **/ + } + {this.renderViewOrManageMembersRow() && + - - } - {canManageUsers && - + + } + {canManageUsers && + - - } - {canEditChannel && ( - - - - - )} - - ); + + } + {canEditChannel && ( + + + + + )} + + + ); }; render() { diff --git a/app/screens/channel_info/channel_info_row.js b/app/screens/channel_info/channel_info_row.js index 10413be06..a8c59fc23 100644 --- a/app/screens/channel_info/channel_info_row.js +++ b/app/screens/channel_info/channel_info_row.js @@ -46,7 +46,7 @@ function channelInfoRow(props) { iconElement = ( ); } diff --git a/app/screens/index.js b/app/screens/index.js index 6bee81ef7..a6b3986bc 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -46,6 +46,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('NotificationSettingsMobile', () => wrapWithContextProvider(require('app/screens/settings/notification_settings_mobile').default), store, Provider); Navigation.registerComponent('OptionsModal', () => wrapWithContextProvider(require('app/screens/options_modal').default), store, Provider); Navigation.registerComponent('Permalink', () => wrapWithContextProvider(require('app/screens/permalink').default), store, Provider); + Navigation.registerComponent('PinnedPosts', () => wrapWithContextProvider(require('app/screens/pinned_posts').default), store, Provider); Navigation.registerComponent('PostOptions', () => wrapWithContextProvider(require('app/screens/post_options').default), store, Provider); Navigation.registerComponent('ReactionList', () => wrapWithContextProvider(require('app/screens/reaction_list').default), store, Provider); Navigation.registerComponent('RecentMentions', () => wrapWithContextProvider(require('app/screens/recent_mentions').default), store, Provider); diff --git a/app/screens/pinned_posts/index.js b/app/screens/pinned_posts/index.js new file mode 100644 index 000000000..bfc5df20d --- /dev/null +++ b/app/screens/pinned_posts/index.js @@ -0,0 +1,51 @@ +// 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, getPinnedPosts} 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 {showSearchModal} from 'app/actions/views/search'; +import {makePreparePostIdsForSearchPosts} from 'app/selectors/post_list'; + +import PinnedPosts from './pinned_posts'; + +function makeMapStateToProps() { + const preparePostIds = makePreparePostIdsForSearchPosts(); + return (state, ownProps) => { + const {pinned} = state.entities.search; + const channelPinnedPosts = pinned[ownProps.currentChannelId] || []; + const postIds = preparePostIds(state, channelPinnedPosts); + const {pinnedPosts: pinnedPostsRequest} = state.requests.search; + const isLoading = pinnedPostsRequest.status === RequestStatus.STARTED || + pinnedPostsRequest.status === RequestStatus.NOT_STARTED; + + return { + postIds, + isLoading, + didFail: pinnedPostsRequest.status === RequestStatus.FAILURE, + theme: getTheme(state), + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + clearSearch, + loadChannelsByTeamName, + loadThreadIfNecessary, + getPinnedPosts, + selectFocusedPostId, + selectPost, + showSearchModal, + }, dispatch), + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(PinnedPosts); diff --git a/app/screens/pinned_posts/pinned_posts.js b/app/screens/pinned_posts/pinned_posts.js new file mode 100644 index 000000000..75b5768ff --- /dev/null +++ b/app/screens/pinned_posts/pinned_posts.js @@ -0,0 +1,288 @@ +// 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, + StyleSheet, + SafeAreaView, + View, +} from 'react-native'; + +import ChannelLoader from 'app/components/channel_loader'; +import DateHeader from 'app/components/post_list/date_header'; +import {isDateLine} from 'app/components/post_list/date_header/utils'; +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 {changeOpacity} from 'app/utils/theme'; +import noResultsImage from 'assets/images/no_results/pin.png'; + +export default class PinnedPosts extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + clearSearch: PropTypes.func.isRequired, + loadChannelsByTeamName: PropTypes.func.isRequired, + loadThreadIfNecessary: PropTypes.func.isRequired, + getPinnedPosts: PropTypes.func.isRequired, + selectFocusedPostId: PropTypes.func.isRequired, + selectPost: PropTypes.func.isRequired, + showSearchModal: PropTypes.func.isRequired, + }).isRequired, + currentChannelId: PropTypes.string.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); + + this.state = { + managedConfig: {}, + }; + } + + componentWillMount() { + this.listenerId = mattermostManaged.addEventListener('change', this.setManagedConfig); + } + + componentDidMount() { + const {actions, currentChannelId} = this.props; + this.setManagedConfig(); + actions.clearSearch(); + actions.getPinnedPosts(currentChannelId); + } + + 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); + }; + + handleHashtagPress = async (hashtag) => { + const {actions, navigator} = this.props; + + await navigator.dismissModal(); + + actions.showSearchModal(navigator, '#' + hashtag); + }; + + 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 (isDateLine(item)) { + return ( + + ); + } + + let separator; + const nextPost = postIds[index + 1]; + if (nextPost && !isDateLine(nextPost)) { + 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, + }, + }; + + this.showingPermalink = true; + navigator.showModal(options); + } + }; + + retry = () => { + const {actions, currentChannelId} = this.props; + actions.getPinnedPosts(currentChannelId); + }; + + 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/post_options/index.js b/app/screens/post_options/index.js index ac7774d36..a773f1afe 100644 --- a/app/screens/post_options/index.js +++ b/app/screens/post_options/index.js @@ -4,7 +4,14 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {deletePost, flagPost, unflagPost, removePost} from 'mattermost-redux/actions/posts'; +import { + deletePost, + flagPost, + pinPost, + unflagPost, + unpinPost, + removePost, +} from 'mattermost-redux/actions/posts'; import {General, Permissions} from 'mattermost-redux/constants'; import {getChannel, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -72,8 +79,10 @@ function mapDispatchToProps(dispatch) { addReaction, deletePost, flagPost, + pinPost, removePost, unflagPost, + unpinPost, }, dispatch), }; } diff --git a/app/screens/post_options/post_option.js b/app/screens/post_options/post_option.js index 38f80c25f..2dbeb0dda 100644 --- a/app/screens/post_options/post_option.js +++ b/app/screens/post_options/post_option.js @@ -16,6 +16,7 @@ import edit from 'assets/images/post_menu/edit.png'; import emoji from 'assets/images/post_menu/emoji.png'; import flag from 'assets/images/post_menu/flag.png'; import link from 'assets/images/post_menu/link.png'; +import pin from 'assets/images/post_menu/pin.png'; import trash from 'assets/images/post_menu/trash.png'; const icons = { @@ -24,6 +25,7 @@ const icons = { emoji, flag, link, + pin, trash, }; diff --git a/app/screens/post_options/post_options.js b/app/screens/post_options/post_options.js index 4c6526b89..7d4bf2352 100644 --- a/app/screens/post_options/post_options.js +++ b/app/screens/post_options/post_options.js @@ -21,8 +21,10 @@ export default class PostOptions extends PureComponent { addReaction: PropTypes.func.isRequired, deletePost: PropTypes.func.isRequired, flagPost: PropTypes.func.isRequired, + pinPost: PropTypes.func.isRequired, removePost: PropTypes.func.isRequired, unflagPost: PropTypes.func.isRequired, + unpinPost: PropTypes.func.isRequired, }).isRequired, additionalOption: PropTypes.object, canAddReaction: PropTypes.bool, @@ -159,7 +161,7 @@ export default class PostOptions extends PureComponent { ); @@ -169,16 +171,46 @@ export default class PostOptions extends PureComponent { ); }; + getPinOption = () => { + const {formatMessage} = this.context.intl; + const {channelIsReadOnly, post} = this.props; + + if (channelIsReadOnly) { + return null; + } + + if (post.is_pinned) { + return ( + + ); + } + + return ( + + ); + }; + getMyPostOptions = () => { const actions = [ this.getEditOption(), this.getFlagOption(), + this.getPinOption(), this.getAddReactionOption(), this.getCopyPermalink(), this.getCopyText(), @@ -192,6 +224,7 @@ export default class PostOptions extends PureComponent { const actions = [ this.getFlagOption(), this.getAddReactionOption(), + this.getPinOption(), this.getCopyPermalink(), this.getCopyText(), this.getEditOption(), @@ -265,6 +298,13 @@ export default class PostOptions extends PureComponent { this.closeWithAnimation(); }; + handlePinPost = () => { + const {actions, post} = this.props; + + actions.pinPost(post.id); + this.closeWithAnimation(); + }; + handlePostDelete = () => { const {formatMessage} = this.context.intl; const {actions, isMyPost, post} = this.props; @@ -325,6 +365,13 @@ export default class PostOptions extends PureComponent { this.closeWithAnimation(); }; + handleUnpinPost = () => { + const {actions, post} = this.props; + + actions.unpinPost(post.id); + this.closeWithAnimation(); + }; + refSlideUpPanel = (r) => { this.slideUpPanel = r; }; @@ -338,6 +385,7 @@ export default class PostOptions extends PureComponent { return (