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 (