diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index 57e0c6cf23..a5064df3d7 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -316,12 +316,19 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m } if (channel) { + const unreadMessageCount = channel.total_msg_count - member.msg_count; actions.push({ + type: ChannelTypes.SET_UNREAD_MSG_COUNT, + data: { + channelId, + count: unreadMessageCount, + }, + }, { type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT, data: { teamId: channel.team_id, channelId, - amount: channel.total_msg_count - member.msg_count, + amount: unreadMessageCount, }, }, { type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT, @@ -728,3 +735,15 @@ function loadSidebar(data) { } }; } + +export function resetUnreadMessageCount(channelId) { + return async (dispatch) => { + dispatch({ + type: ChannelTypes.SET_UNREAD_MSG_COUNT, + data: { + channelId, + count: 0, + }, + }); + }; +} diff --git a/app/actions/websocket.ts b/app/actions/websocket.ts index 96f29215b1..8290dd88f0 100644 --- a/app/actions/websocket.ts +++ b/app/actions/websocket.ts @@ -354,7 +354,13 @@ function handleNewPostEvent(msg: WebSocketMessage) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const state = getState(); const currentChannelId = getCurrentChannelId(state); - const post = JSON.parse(msg.data.post); + const currentUserId = getCurrentUserId(state); + const data = JSON.parse(msg.data.post); + const post = { + ...data, + ownPost: data.user_id === currentUserId, + }; + const actions: Array = []; const exists = selectPost(state, post.pending_post_id); @@ -424,7 +430,6 @@ function handleNewPostEvent(msg: WebSocketMessage) { } if (msg.data.channel_type === General.DM_CHANNEL) { - const currentUserId = getCurrentUserId(state); const otherUserId = getUserIdFromChannelName(currentUserId, msg.data.channel_name); const dmAction = makeDirectChannelVisibleIfNecessary(state, otherUserId); if (dmAction) { @@ -472,11 +477,17 @@ function handleNewPostEvent(msg: WebSocketMessage) { } function handlePostEdited(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc) => { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); const data = JSON.parse(msg.data.post); - const actions = [receivedPost(data)]; + const post = { + ...data, + ownPost: data.user_id === currentUserId, + }; + const actions = [receivedPost(post)]; - const additional: any = await dispatch(getPostsAdditionalDataBatch([data])); + const additional: any = await dispatch(getPostsAdditionalDataBatch([post])); if (additional.data.length) { actions.push(...additional.data); } diff --git a/app/components/network_indicator/__snapshots__/network_indicator.test.js.snap b/app/components/network_indicator/__snapshots__/network_indicator.test.js.snap new file mode 100644 index 0000000000..3defdcfb28 --- /dev/null +++ b/app/components/network_indicator/__snapshots__/network_indicator.test.js.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AttachmentFooter matches snapshot 1`] = ` + + + + + + + + +`; diff --git a/app/components/network_indicator/network_indicator.js b/app/components/network_indicator/network_indicator.js index 625628ffce..046e559253 100644 --- a/app/components/network_indicator/network_indicator.js +++ b/app/components/network_indicator/network_indicator.js @@ -14,16 +14,17 @@ import { import NetInfo from '@react-native-community/netinfo'; import IonIcon from 'react-native-vector-icons/Ionicons'; +import {RequestStatus} from '@mm-redux/constants'; +import EventEmitter from '@mm-redux/utils/event_emitter'; + import FormattedText from 'app/components/formatted_text'; -import {DeviceTypes, ViewTypes} from 'app/constants'; +import {DeviceTypes, ViewTypes} from '@constants'; +import {INDICATOR_BAR_HEIGHT} from '@constants/view'; import mattermostBucket from 'app/mattermost_bucket'; import PushNotifications from 'app/push_notifications'; import networkConnectionListener, {checkConnection} from 'app/utils/network'; import {t} from 'app/utils/i18n'; -import {RequestStatus} from '@mm-redux/constants'; - -const HEIGHT = 38; const MAX_WEBSOCKET_RETRIES = 3; const CONNECTION_RETRY_SECONDS = 5; const CONNECTION_RETRY_TIMEOUT = 1000 * CONNECTION_RETRY_SECONDS; // 30 seconds @@ -67,7 +68,7 @@ export default class NetworkIndicator extends PureComponent { }; const navBar = this.getNavBarHeight(props.isLandscape); - this.top = new Animated.Value(navBar - HEIGHT); + this.top = new Animated.Value(navBar - INDICATOR_BAR_HEIGHT); this.clearNotificationTimeout = null; this.backgroundColor = new Animated.Value(0); @@ -102,7 +103,7 @@ export default class NetworkIndicator extends PureComponent { if (isLandscape !== prevIsLandscape) { const navBar = this.getNavBarHeight(isLandscape); - const initialTop = websocketErrorCount || previousWebsocketStatus === RequestStatus.FAILURE || previousWebsocketStatus === RequestStatus.NOT_STARTED ? 0 : HEIGHT; + const initialTop = websocketErrorCount || previousWebsocketStatus === RequestStatus.FAILURE || previousWebsocketStatus === RequestStatus.NOT_STARTED ? 0 : INDICATOR_BAR_HEIGHT; this.top.setValue(navBar - initialTop); } @@ -166,28 +167,32 @@ export default class NetworkIndicator extends PureComponent { }; connected = () => { - Animated.sequence([ - Animated.timing( - this.backgroundColor, { - toValue: 1, - duration: 100, - useNativeDriver: false, - }, - ), - Animated.timing( - this.top, { - toValue: (this.getNavBarHeight() - HEIGHT), - duration: 300, - delay: 500, - useNativeDriver: false, - }, - ), - ]).start(() => { - this.backgroundColor.setValue(0); - this.setState({ - opacity: 0, + if (this.visible) { + this.visible = false; + Animated.sequence([ + Animated.timing( + this.backgroundColor, { + toValue: 1, + duration: 100, + useNativeDriver: false, + }, + ), + Animated.timing( + this.top, { + toValue: (this.getNavBarHeight() - INDICATOR_BAR_HEIGHT), + duration: 300, + delay: 500, + useNativeDriver: false, + }, + ), + ]).start(() => { + EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, false); + this.backgroundColor.setValue(0); + this.setState({ + opacity: 0, + }); }); - }); + } }; getNavBarHeight = (isLandscape = this.props.isLandscape) => { @@ -314,19 +319,23 @@ export default class NetworkIndicator extends PureComponent { }; show = () => { - this.setState({ - opacity: 1, - }); + if (!this.visible) { + this.visible = true; + EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, true); + this.setState({ + opacity: 1, + }); - Animated.timing( - this.top, { - toValue: this.getNavBarHeight(), - duration: 300, - useNativeDriver: false, - }, - ).start(() => { - this.props.actions.setCurrentUserStatusOffline(); - }); + Animated.timing( + this.top, { + toValue: this.getNavBarHeight(), + duration: 300, + useNativeDriver: false, + }, + ).start(() => { + this.props.actions.setCurrentUserStatusOffline(); + }); + } }; render() { @@ -396,7 +405,7 @@ export default class NetworkIndicator extends PureComponent { const styles = StyleSheet.create({ container: { - height: HEIGHT, + height: INDICATOR_BAR_HEIGHT, width: '100%', position: 'absolute', ...Platform.select({ @@ -411,7 +420,7 @@ const styles = StyleSheet.create({ wrapper: { alignItems: 'center', flex: 1, - height: HEIGHT, + height: INDICATOR_BAR_HEIGHT, flexDirection: 'row', paddingLeft: 12, paddingRight: 5, diff --git a/app/components/network_indicator/network_indicator.test.js b/app/components/network_indicator/network_indicator.test.js new file mode 100644 index 0000000000..e7cb42dbcd --- /dev/null +++ b/app/components/network_indicator/network_indicator.test.js @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Animated} from 'react-native'; +import {shallow} from 'enzyme'; + +import EventEmitter from '@mm-redux/utils/event_emitter'; + +import {ViewTypes} from '@constants'; + +import NetworkIndicator from './network_indicator'; + +describe('AttachmentFooter', () => { + Animated.sequence = jest.fn(() => ({ + start: jest.fn((cb) => cb()), + })); + Animated.timing = jest.fn(() => ({ + start: jest.fn(), + })); + + const baseProps = { + actions: { + closeWebSocket: jest.fn(), + connection: jest.fn(), + initWebSocket: jest.fn(), + markChannelViewedAndReadOnReconnect: jest.fn(), + logout: jest.fn(), + setChannelRetryFailed: jest.fn(), + setCurrentUserStatusOffline: jest.fn(), + startPeriodicStatusUpdates: jest.fn(), + stopPeriodicStatusUpdates: jest.fn(), + }, + }; + + it('matches snapshot', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + describe('show', () => { + EventEmitter.emit = jest.fn(); + const wrapper = shallow(); + const instance = wrapper.instance(); + + it('emits INDICATOR_BAR_VISIBLE with true only if not already visible', () => { + instance.visible = true; + instance.show(); + expect(EventEmitter.emit).not.toHaveBeenCalled(); + + instance.visible = false; + instance.show(); + expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, true); + expect(instance.visible).toBe(true); + }); + }); + + describe('connected', () => { + EventEmitter.emit = jest.fn(); + const wrapper = shallow(); + const instance = wrapper.instance(); + + it('emits INDICATOR_BAR_VISIBLE with false only if visible', () => { + instance.visible = false; + instance.connected(); + expect(EventEmitter.emit).not.toHaveBeenCalled(); + + instance.visible = true; + instance.connected(); + expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, false); + expect(instance.visible).toBe(false); + }); + }); +}); diff --git a/app/components/post_list/__snapshots__/post_list.test.js.snap b/app/components/post_list/__snapshots__/post_list.test.js.snap index ac0e6c40b1..8e6ef4538c 100644 --- a/app/components/post_list/__snapshots__/post_list.test.js.snap +++ b/app/components/post_list/__snapshots__/post_list.test.js.snap @@ -1,208 +1,232 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`PostList setting channel deep link 1`] = ` - + - } - removeClippedSubviews={false} - renderItem={[Function]} - scrollEventThrottle={60} - style={ - Object { - "flex": 1, } - } - updateCellsBatchingPeriod={50} - windowSize={50} -/> + data={ + Array [ + "post-id-1", + "post-id-2", + ] + } + disableVirtualization={false} + extraData={ + Array [ + "channel-id", + undefined, + undefined, + undefined, + ] + } + horizontal={false} + initialNumToRender={10} + inverted={true} + keyExtractor={[Function]} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + listKey="recyclerFor-channel-id" + maintainVisibleContentPosition={ + Object { + "autoscrollToTopThreshold": 60, + "minIndexForVisible": 0, + } + } + maxToRenderPerBatch={10} + numColumns={1} + onContentSizeChange={[Function]} + onEndReachedThreshold={2} + onLayout={[Function]} + onScroll={[Function]} + onScrollToIndexFailed={[Function]} + onViewableItemsChanged={null} + refreshControl={ + + } + removeClippedSubviews={false} + renderItem={[Function]} + scrollEventThrottle={60} + style={ + Object { + "flex": 1, + } + } + updateCellsBatchingPeriod={50} + viewabilityConfig={ + Object { + "itemVisiblePercentThreshold": 1, + "minimumViewTime": 100, + } + } + windowSize={30} + /> + `; exports[`PostList setting permalink deep link 1`] = ` - + - } - removeClippedSubviews={false} - renderItem={[Function]} - scrollEventThrottle={60} - style={ - Object { - "flex": 1, } - } - updateCellsBatchingPeriod={50} - windowSize={50} -/> + data={ + Array [ + "post-id-1", + "post-id-2", + ] + } + disableVirtualization={false} + extraData={ + Array [ + "channel-id", + undefined, + undefined, + undefined, + ] + } + horizontal={false} + initialNumToRender={10} + inverted={true} + keyExtractor={[Function]} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + listKey="recyclerFor-channel-id" + maintainVisibleContentPosition={ + Object { + "autoscrollToTopThreshold": 60, + "minIndexForVisible": 0, + } + } + maxToRenderPerBatch={10} + numColumns={1} + onContentSizeChange={[Function]} + onEndReachedThreshold={2} + onLayout={[Function]} + onScroll={[Function]} + onScrollToIndexFailed={[Function]} + onViewableItemsChanged={null} + refreshControl={ + + } + removeClippedSubviews={false} + renderItem={[Function]} + scrollEventThrottle={60} + style={ + Object { + "flex": 1, + } + } + updateCellsBatchingPeriod={50} + viewabilityConfig={ + Object { + "itemVisiblePercentThreshold": 1, + "minimumViewTime": 100, + } + } + windowSize={30} + /> + `; exports[`PostList should match snapshot 1`] = ` - + - } - removeClippedSubviews={false} - renderItem={[Function]} - scrollEventThrottle={60} - style={ - Object { - "flex": 1, } - } - updateCellsBatchingPeriod={50} - windowSize={50} -/> + data={ + Array [ + "post-id-1", + "post-id-2", + ] + } + disableVirtualization={false} + extraData={ + Array [ + "channel-id", + undefined, + undefined, + undefined, + ] + } + horizontal={false} + initialNumToRender={10} + inverted={true} + keyExtractor={[Function]} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + listKey="recyclerFor-channel-id" + maintainVisibleContentPosition={ + Object { + "autoscrollToTopThreshold": 60, + "minIndexForVisible": 0, + } + } + maxToRenderPerBatch={10} + numColumns={1} + onContentSizeChange={[Function]} + onEndReachedThreshold={2} + onLayout={[Function]} + onScroll={[Function]} + onScrollToIndexFailed={[Function]} + onViewableItemsChanged={null} + refreshControl={ + + } + removeClippedSubviews={false} + renderItem={[Function]} + scrollEventThrottle={60} + style={ + Object { + "flex": 1, + } + } + updateCellsBatchingPeriod={50} + viewabilityConfig={ + Object { + "itemVisiblePercentThreshold": 1, + "minimumViewTime": 100, + } + } + windowSize={30} + /> + `; diff --git a/app/components/post_list/more_messages_button/__snapshots__/more_messages_button.test.js.snap b/app/components/post_list/more_messages_button/__snapshots__/more_messages_button.test.js.snap new file mode 100644 index 0000000000..5be5eb9a77 --- /dev/null +++ b/app/components/post_list/more_messages_button/__snapshots__/more_messages_button.test.js.snap @@ -0,0 +1,139 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MoreMessagesButton should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/post_list/more_messages_button/index.js b/app/components/post_list/more_messages_button/index.js new file mode 100644 index 0000000000..d07288df0f --- /dev/null +++ b/app/components/post_list/more_messages_button/index.js @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import MoreMessagesButton from './more_messages_button'; + +function mapStateToProps(state, ownProps) { + const {channelId} = ownProps; + const unreadCount = state.views.channel.unreadMessageCount[channelId] || 0; + const loadingPosts = Boolean(state.views.channel.loadingPosts[channelId]); + const manuallyUnread = Boolean(state.entities.channels.manuallyUnread[channelId]); + + return { + unreadCount, + loadingPosts, + manuallyUnread, + }; +} + +export default connect(mapStateToProps)(MoreMessagesButton); diff --git a/app/components/post_list/more_messages_button/more_messages_button.js b/app/components/post_list/more_messages_button/more_messages_button.js new file mode 100644 index 0000000000..dbf36b341c --- /dev/null +++ b/app/components/post_list/more_messages_button/more_messages_button.js @@ -0,0 +1,397 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {ActivityIndicator, Animated, Text, View} from 'react-native'; +import {intlShape} from 'react-intl'; +import PropTypes from 'prop-types'; + +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {messageCount} from '@mm-redux/utils/post_list'; + +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import VectorIcon from '@components/vector_icon'; +import ViewTypes, {INDICATOR_BAR_HEIGHT} from '@constants/view'; +import {makeStyleSheetFromTheme, hexToHue} from '@utils/theme'; +import {t} from '@utils/i18n'; + +const HIDDEN_TOP = -100; +const SHOWN_TOP = 0; +export const INDICATOR_BAR_FACTOR = Math.abs(INDICATOR_BAR_HEIGHT / (HIDDEN_TOP - SHOWN_TOP)); +export const MIN_INPUT = 0; +export const MAX_INPUT = 1; + +const TOP_INTERPOL_CONFIG = { + inputRange: [ + MIN_INPUT, + MIN_INPUT + INDICATOR_BAR_FACTOR, + MAX_INPUT - INDICATOR_BAR_FACTOR, + MAX_INPUT, + ], + outputRange: [ + HIDDEN_TOP - INDICATOR_BAR_HEIGHT, + HIDDEN_TOP, + SHOWN_TOP, + SHOWN_TOP + INDICATOR_BAR_HEIGHT, + ], + extrapolate: 'clamp', +}; + +export const CANCEL_TIMER_DELAY = 400; + +export default class MoreMessageButton extends React.PureComponent { + static propTypes = { + theme: PropTypes.object.isRequired, + postIds: PropTypes.array.isRequired, + channelId: PropTypes.string.isRequired, + loadingPosts: PropTypes.bool.isRequired, + unreadCount: PropTypes.number.isRequired, + manuallyUnread: PropTypes.bool.isRequired, + newMessageLineIndex: PropTypes.number.isRequired, + scrollToIndex: PropTypes.func.isRequired, + registerViewableItemsListener: PropTypes.func.isRequired, + registerScrollEndIndexListener: PropTypes.func.isRequired, + deepLinkURL: PropTypes.string, + }; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props) { + super(props); + + this.state = {moreText: ''}; + this.top = new Animated.Value(0); + this.disableViewableItems = false; + this.viewableItems = []; + } + + componentDidMount() { + EventEmitter.on(ViewTypes.INDICATOR_BAR_VISIBLE, this.onIndicatorBarVisible); + this.removeViewableItemsListener = this.props.registerViewableItemsListener(this.onViewableItemsChanged); + this.removeScrollEndIndexListener = this.props.registerScrollEndIndexListener(this.onScrollEndIndex); + } + + componentWillUnmount() { + EventEmitter.off(ViewTypes.INDICATOR_BAR_VISIBLE, this.onIndicatorBarVisible); + if (this.removeViewableItemsListener) { + this.removeViewableItemsListener(); + } + if (this.removeScrollEndIndexListener) { + this.removeScrollEndIndexListener(); + } + } + + componentDidUpdate(prevProps) { + const {channelId, unreadCount, newMessageLineIndex, manuallyUnread} = this.props; + + if (channelId !== prevProps.channelId) { + this.reset(); + return; + } + + if ((manuallyUnread && !prevProps.manuallyUnread) || newMessageLineIndex === -1) { + this.cancel(true); + return; + } + + if (newMessageLineIndex !== prevProps.newMessageLineIndex) { + if (this.autoCancelTimer) { + clearTimeout(this.autoCancelTimer); + this.autoCancelTimer = null; + } + this.pressed = false; + this.uncancel(); + + const readCount = this.getReadCount(prevProps.newMessageLineIndex); + this.showMoreText(readCount); + } + + // The unreadCount might not be set until after all the viewable items are rendered. + // In this case we want to manually call onViewableItemsChanged with the stored + // viewableItems. + if (unreadCount > prevProps.unreadCount && prevProps.unreadCount === 0) { + this.onViewableItemsChanged(this.viewableItems); + } + } + + onIndicatorBarVisible = (indicatorVisible) => { + this.indicatorBarVisible = indicatorVisible; + if (this.buttonVisible) { + const toValue = this.indicatorBarVisible ? MAX_INPUT : MAX_INPUT - INDICATOR_BAR_FACTOR; + Animated.spring(this.top, { + toValue, + useNativeDriver: true, + }).start(); + } + } + + reset = () => { + if (this.autoCancelTimer) { + clearTimeout(this.autoCancelTimer); + this.autoCancelTimer = null; + } + this.hide(); + this.setState({moreText: ''}); + this.viewableItems = []; + this.pressed = false; + this.canceled = false; + this.disableViewableItems = false; + } + + show = () => { + if (!this.buttonVisible && this.state.moreText && !this.props.deepLinkURL && !this.canceled && this.props.unreadCount > 0) { + this.buttonVisible = true; + const toValue = this.indicatorBarVisible ? MAX_INPUT : MAX_INPUT - INDICATOR_BAR_FACTOR; + Animated.spring(this.top, { + toValue, + useNativeDriver: true, + }).start(); + } + } + + hide = () => { + if (this.buttonVisible) { + this.buttonVisible = false; + const toValue = this.indicatorBarVisible ? MIN_INPUT : MIN_INPUT + INDICATOR_BAR_FACTOR; + Animated.spring(this.top, { + toValue, + useNativeDriver: true, + }).start(); + } + } + + cancel = (force = false) => { + if (!force && (this.indicatorBarVisible || this.props.loadingPosts)) { + // If we haven't forced cancel and the indicator bar is visible or + // we're still loading posts, then to avoid the autoCancelTimer from + // hiding the button we continue delaying the cancel call. + clearTimeout(this.autoCancelTimer); + this.autoCancelTimer = setTimeout(this.cancel, CANCEL_TIMER_DELAY); + + return; + } + + this.canceled = true; + this.disableViewableItems = true; + this.hide(); + } + + uncancel = () => { + if (this.autoCancelTimer) { + clearTimeout(this.autoCancelTimer); + this.autoCancelTimer = null; + } + this.canceled = false; + this.disableViewableItems = false; + } + + onMoreMessagesPress = () => { + if (this.pressed) { + // Prevent multiple taps on the more messages button + // from calling scrollToIndex. + return; + } + + const {newMessageLineIndex, scrollToIndex} = this.props; + this.pressed = true; + scrollToIndex(newMessageLineIndex); + } + + onViewableItemsChanged = (viewableItems) => { + this.viewableItems = viewableItems; + + const {newMessageLineIndex, scrollToIndex, unreadCount} = this.props; + if (newMessageLineIndex <= 0 || unreadCount === 0 || viewableItems.length === 0 || this.disableViewableItems) { + return; + } + + const lastViewableIndex = viewableItems[viewableItems.length - 1].index; + const nextViewableIndex = lastViewableIndex + 1; + if (viewableItems[0].index === 0 && nextViewableIndex >= newMessageLineIndex) { + // Auto scroll if the first post is viewable and + // * the new message line is viewable OR + // * the new message line will be the first next viewable item + scrollToIndex(newMessageLineIndex); + this.cancel(true); + + return; + } + + let readCount; + if (lastViewableIndex >= newMessageLineIndex) { + readCount = this.getReadCount(lastViewableIndex); + const moreCount = this.props.unreadCount - readCount; + if (moreCount <= 0) { + this.cancel(true); + } else { + // In some cases the last New Message line is reached but, since the + // unreadCount is off, the button will never hide. We call cancel with + // a delay for this case and clear the timer in componentDidUpdate if + // and when the newMessageLineIndex changes. + this.autoCancelTimer = setTimeout(this.cancel, CANCEL_TIMER_DELAY); + } + } else if (this.endIndex && lastViewableIndex >= this.endIndex) { + // If auto-scrolling failed ot reach the New Message line, update + // the button text to reflect the read count up to the item that + // was auto-scrolled to. + readCount = this.getReadCount(this.endIndex); + this.endIndex = null; + this.showMoreText(readCount); + } else if (this.state.moreText === '') { + readCount = 0; + this.showMoreText(readCount); + } + } + + showMoreText = (readCount) => { + const moreCount = this.props.unreadCount - readCount; + + if (moreCount <= 0) { + this.cancel(true); + return; + } + + const moreText = this.moreText(moreCount); + this.setState({moreText}, this.show); + } + + getReadCount = (lastViewableIndex) => { + const {postIds} = this.props; + + const viewedPostIds = postIds.slice(0, lastViewableIndex + 1); + const readCount = messageCount(viewedPostIds); + + return readCount; + } + + moreText = (count) => { + const {unreadCount} = this.props; + const {formatMessage} = this.context.intl; + const isInitialMessage = unreadCount === count; + + return formatMessage({ + id: t('mobile.more_messages_button.text'), + defaultMessage: '{count} {isInitialMessage, select, true {new} other {more new}} {count, plural, one {message} other {messages}}', + }, {isInitialMessage, count}); + } + + onScrollEndIndex = (endIndex) => { + this.pressed = false; + this.endIndex = endIndex; + } + + render() { + const {theme, loadingPosts} = this.props; + + const styles = getStyleSheet(theme); + const {moreText} = this.state; + const translateY = this.top.interpolate(TOP_INTERPOL_CONFIG); + const underlayColor = `hsl(${hexToHue(theme.buttonBg)}, 50%, 38%)`; + + return ( + + + + + {loadingPosts && + + } + {!loadingPosts && + + } + + + {moreText} + + + + + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + animatedContainer: { + flex: 1, + position: 'absolute', + zIndex: 1, + elevation: 1, + margin: 8, + backgroundColor: theme.buttonBg, + }, + container: { + flex: 1, + flexDirection: 'row', + justifyContent: 'space-evenly', + alignItems: 'center', + paddingLeft: 11, + paddingRight: 5, + paddingVertical: 1, + width: '100%', + height: 40, + shadowColor: theme.centerChannelColor, + shadowOffset: { + width: 0, + height: 6, + }, + shadowOpacity: 0.12, + shadowRadius: 4, + }, + roundBorder: { + borderRadius: 4, + }, + iconContainer: { + width: 22, + }, + textContainer: { + flex: 10, + flexDirection: 'row', + alignItems: 'flex-start', + paddingLeft: 4, + }, + cancelContainer: { + flex: 1, + alignItems: 'flex-end', + }, + text: { + fontWeight: 'bold', + color: theme.buttonColor, + paddingLeft: 0, + alignSelf: 'center', + }, + icon: { + fontSize: 18, + fontWeight: 'bold', + color: theme.buttonColor, + alignSelf: 'center', + }, + }; +}); diff --git a/app/components/post_list/more_messages_button/more_messages_button.test.js b/app/components/post_list/more_messages_button/more_messages_button.test.js new file mode 100644 index 0000000000..04618ddc2a --- /dev/null +++ b/app/components/post_list/more_messages_button/more_messages_button.test.js @@ -0,0 +1,845 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Animated} from 'react-native'; +import {shallowWithIntl} from 'test/intl-test-helper'; + +import Preferences from '@mm-redux/constants/preferences'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import * as PostListUtils from '@mm-redux/utils/post_list'; + +import ViewTypes from '@constants/view'; + +import MoreMessagesButton, { + MIN_INPUT, + MAX_INPUT, + INDICATOR_BAR_FACTOR, + CANCEL_TIMER_DELAY, +} from './more_messages_button.js'; + +describe('MoreMessagesButton', () => { + const baseProps = { + theme: Preferences.THEMES.default, + postIds: [], + channelId: 'channel-id', + unreadCount: 10, + loadingPosts: false, + manuallyUnread: false, + newMessageLineIndex: 0, + scrollToIndex: jest.fn(), + registerViewableItemsListener: jest.fn(() => { + return jest.fn(); + }), + registerScrollEndIndexListener: jest.fn(() => { + return jest.fn(); + }), + }; + + it('should match snapshot', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + describe('constructor', () => { + it('should set state and instance values', () => { + const instance = new MoreMessagesButton({...baseProps}); + expect(instance.state).toStrictEqual({moreText: ''}); + expect(instance.top).toEqual(new Animated.Value(0)); + expect(instance.disableViewableItems).toBe(false); + }); + }); + + describe('lifecycle methods', () => { + test('componentDidMount should register indicator visibility listener, viewable items listener, and scroll end index listener', () => { + EventEmitter.on = jest.fn(); + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.onIndicatorBarVisible = jest.fn(); + instance.onViewableItemsChanged = jest.fn(); + instance.onScrollEndIndex = jest.fn(); + + // While componentDidMount is called when the component is mounted with `shallow()` above, + // instance.onIndicatorBarVisible, instance.onViewableItemsChanged, and instance.onScrollEndIndex + // have not yet been mocked so we call componentDidMount again. + instance.componentDidMount(); + + expect(EventEmitter.on).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, instance.onIndicatorBarVisible); + expect(baseProps.registerViewableItemsListener).toHaveBeenCalledWith(instance.onViewableItemsChanged); + expect(instance.removeViewableItemsListener).toBeDefined(); + expect(baseProps.registerScrollEndIndexListener).toHaveBeenCalledWith(instance.onScrollEndIndex); + expect(instance.removeScrollEndIndexListener).toBeDefined(); + }); + + test('componentWillUnmount should remove the indicator bar visible listener, the viewable items listener, the scroll end index listener, and clear all timers', () => { + jest.useFakeTimers(); + EventEmitter.off = jest.fn(); + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.onIndicatorBarVisible = jest.fn(); + instance.removeViewableItemsListener = jest.fn(); + instance.removeScrollEndIndexListener = jest.fn(); + + instance.componentWillUnmount(); + expect(EventEmitter.off).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, instance.onIndicatorBarVisible); + expect(instance.removeViewableItemsListener).toHaveBeenCalled(); + expect(instance.removeScrollEndIndexListener).toHaveBeenCalled(); + }); + + test('componentDidUpdate should call reset when the channelId changes', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.reset = jest.fn(); + + wrapper.setProps({channelId: baseProps.channelId}); + expect(instance.reset).not.toHaveBeenCalled(); + + wrapper.setProps({channelId: `not-${baseProps.channelId}`}); + expect(instance.reset).toHaveBeenCalled(); + }); + + test('componentDidUpdate should force cancel when the component updates and manuallyUnread changes from falsy to true', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.cancel = jest.fn(); + + wrapper.setProps({test: 1}); + expect(instance.cancel).not.toHaveBeenCalled(); + + wrapper.setProps({manuallyUnread: true}); + expect(instance.cancel).toHaveBeenCalledTimes(1); + expect(instance.cancel.mock.calls[0][0]).toBe(true); + + wrapper.setProps({test: 2}); + expect(instance.cancel).toHaveBeenCalledTimes(1); + + wrapper.setProps({manuallyUnread: false}); + expect(instance.cancel).toHaveBeenCalledTimes(1); + + wrapper.setProps({test: 3}); + expect(instance.cancel).toHaveBeenCalledTimes(1); + + wrapper.setProps({manuallyUnread: true}); + expect(instance.cancel).toHaveBeenCalledTimes(2); + expect(instance.cancel.mock.calls[1][0]).toBe(true); + }); + + test('componentDidUpdate should force cancel when the component updates and newMessageLineIndex is -1', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.cancel = jest.fn(); + + wrapper.setProps({test: 1}); + expect(instance.cancel).not.toHaveBeenCalled(); + + wrapper.setProps({newMessageLineIndex: 2}); + expect(instance.cancel).not.toHaveBeenCalled(); + + wrapper.setProps({newMessageLineIndex: -1}); + expect(instance.cancel).toHaveBeenCalledTimes(1); + expect(instance.cancel.mock.calls[0][0]).toBe(true); + }); + + test('componentDidUpdate should set pressed to false and call uncancel when the newMessageLineIndex changes but is not -1', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.pressed = true; + instance.uncancel = jest.fn(); + instance.viewableItems = [{index: 100}]; + + wrapper.setProps({newMessageLineIndex: baseProps.newMessageLineIndex}); + expect(instance.pressed).toBe(true); + expect(instance.uncancel).not.toHaveBeenCalled(); + + wrapper.setProps({newMessageLineIndex: -1}); + expect(instance.pressed).toBe(true); + expect(instance.uncancel).not.toHaveBeenCalled(); + + wrapper.setProps({newMessageLineIndex: 100}); + expect(instance.pressed).toBe(false); + expect(instance.uncancel).toHaveBeenCalledTimes(1); + + wrapper.setProps({newMessageLineIndex: 101}); + expect(instance.pressed).toBe(false); + expect(instance.uncancel).toHaveBeenCalledTimes(2); + }); + + test('componentDidUpdate should call showMoreText with the read count derived from the previous new message line', () => { + const prevNewMessageLineIndex = 10; + const props = { + ...baseProps, + newMessageLineIndex: prevNewMessageLineIndex, + }; + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.getReadCount = jest.fn((count) => { + return count; + }); + instance.showMoreText = jest.fn(); + + wrapper.setProps({newMessageLineIndex: prevNewMessageLineIndex + 1}); + expect(instance.getReadCount).toHaveBeenCalledWith(prevNewMessageLineIndex); + expect(instance.showMoreText).toHaveBeenCalledWith(10); + }); + + test('componentDidUpdate should call onViewableItemsChanged when the unreadCount increases from 0', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.onViewableItemsChanged = jest.fn(); + instance.viewableItems = [{index: 1}]; + + wrapper.setProps({unreadCount: 0}); + expect(instance.onViewableItemsChanged).not.toHaveBeenCalled(); + + wrapper.setProps({unreadCount: 1}); + expect(instance.onViewableItemsChanged).toHaveBeenCalledTimes(1); + expect(instance.onViewableItemsChanged).toHaveBeenCalledWith(instance.viewableItems); + + wrapper.setProps({unreadCount: 2}); + expect(instance.onViewableItemsChanged).toHaveBeenCalledTimes(1); + }); + }); + + describe('onIndicatorBarVisible', () => { + Animated.spring = jest.fn(() => ({ + start: jest.fn(), + })); + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + + it('should set indicatorBarVisible but not animate if not visible', () => { + instance.buttonVisible = false; + expect(instance.indicatorBarVisible).not.toBeDefined(); + + instance.onIndicatorBarVisible(true); + expect(instance.indicatorBarVisible).toBe(true); + expect(Animated.spring).not.toHaveBeenCalled(); + + instance.onIndicatorBarVisible(false); + expect(instance.indicatorBarVisible).toBe(false); + expect(Animated.spring).not.toHaveBeenCalledTimes(1); + }); + + it('should animate to MAX_INPUT - INDICATOR_BAR_FACTOR if visible and indicator bar hides', () => { + instance.buttonVisible = true; + instance.onIndicatorBarVisible(false); + expect(Animated.spring).toHaveBeenCalledWith(instance.top, { + toValue: MAX_INPUT - INDICATOR_BAR_FACTOR, + useNativeDriver: true, + }); + }); + + it('should animate to MAX_INPUT if visible and indicator becomes visible', () => { + instance.buttonVisible = true; + instance.onIndicatorBarVisible(true); + expect(Animated.spring).toHaveBeenCalledWith(instance.top, { + toValue: MAX_INPUT, + useNativeDriver: true, + }); + }); + }); + + describe('reset', () => { + it('should reset values and call hide', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + wrapper.setProps({unreadCount: 10}); + instance.setState({moreText: '60+ new messages'}); + const autoCancelTimer = jest.fn(); + instance.autoCancelTimer = autoCancelTimer; + instance.hide = jest.fn(); + instance.disableViewableItems = true; + instance.viewableItems = [{index: 1}]; + instance.pressed = true; + instance.canceled = true; + + instance.reset(); + expect(clearTimeout).toHaveBeenCalledWith(autoCancelTimer); + expect(instance.autoCancelTimer).toBeNull(); + expect(instance.hide).toHaveBeenCalled(); + expect(instance.disableViewableItems).toBe(false); + expect(instance.viewableItems).toStrictEqual([]); + expect(instance.pressed).toBe(false); + expect(instance.state.moreText).toEqual(''); + expect(instance.canceled).toBe(false); + }); + }); + + describe('show', () => { + Animated.spring = jest.fn(() => ({ + start: jest.fn(), + })); + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.componentDidUpdate = jest.fn(); + + it('should not animate when already visible', () => { + instance.buttonVisible = true; + wrapper.setState({moreText: '1 new message'}); + wrapper.setProps({deepLinkURL: null}); + instance.canceled = false; + + instance.show(); + expect(Animated.spring).not.toHaveBeenCalled(); + }); + + it('should not animate when not visible but state.moreText is empty', () => { + instance.buttonVisible = false; + wrapper.setState({moreText: ''}); + wrapper.setProps({deepLinkURL: null}); + instance.canceled = false; + + instance.show(); + expect(Animated.spring).not.toHaveBeenCalled(); + }); + + it('should not animate when not visible and state.moreText is not empty but props.deepLinkURL is set', () => { + instance.buttonVisible = false; + wrapper.setState({moreText: '1 new message'}); + wrapper.setProps({deepLinkURL: 'deeplink-url'}); + instance.canceled = false; + + instance.show(); + expect(Animated.spring).not.toHaveBeenCalled(); + }); + + it('should not animate when not visible, state.moreText is not empty and props.deepLinkURL is not set but canceled is true', () => { + instance.buttonVisible = false; + wrapper.setState({moreText: '1 new message'}); + wrapper.setProps({deepLinkURL: null}); + instance.canceled = true; + + instance.show(); + expect(Animated.spring).not.toHaveBeenCalled(); + }); + + it('should animate when not visible, state.moreText is not empty, props.deepLinkURL is not set, canceled is false, but unreadCount is 0', () => { + instance.buttonVisible = false; + wrapper.setState({moreText: '1 new message'}); + wrapper.setProps({deepLinkURL: null, unreadCount: 0}); + instance.canceled = false; + + instance.show(); + expect(Animated.spring).not.toHaveBeenCalled(); + }); + + it('should animate when not visible, state.moreText is not empty, props.deepLinkURL is not set, canceled is false, and unreadCount is > 0', () => { + instance.buttonVisible = false; + wrapper.setState({moreText: '1 new message'}); + wrapper.setProps({deepLinkURL: null, unreadCount: 1}); + instance.canceled = false; + + instance.show(); + expect(instance.buttonVisible).toBe(true); + expect(Animated.spring).toHaveBeenCalledWith(instance.top, { + toValue: MAX_INPUT - INDICATOR_BAR_FACTOR, + useNativeDriver: true, + }); + }); + + it('should account for the indicator bar height when the indicator is visible', () => { + instance.indicatorBarVisible = true; + instance.buttonVisible = false; + wrapper.setState({moreText: '1 new message'}); + wrapper.setProps({deepLinkURL: null, unreadCount: 1}); + instance.canceled = false; + + instance.show(); + expect(instance.buttonVisible).toBe(true); + expect(Animated.spring).toHaveBeenCalledWith(instance.top, { + toValue: MAX_INPUT, + useNativeDriver: true, + }); + }); + }); + + describe('hide', () => { + Animated.spring = jest.fn(() => ({ + start: jest.fn(), + })); + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + + it('should not animate when not visible', () => { + instance.buttonVisible = false; + + instance.hide(); + expect(Animated.spring).not.toHaveBeenCalled(); + }); + + it('should animate when visible', () => { + instance.buttonVisible = true; + + instance.hide(); + expect(instance.buttonVisible).toBe(false); + expect(Animated.spring).toHaveBeenCalledWith(instance.top, { + toValue: MIN_INPUT + INDICATOR_BAR_FACTOR, + useNativeDriver: true, + }); + }); + + it('should account for the indicator bar height when the indicator is visible', () => { + instance.indicatorBarVisible = true; + instance.buttonVisible = true; + + instance.hide(); + expect(instance.buttonVisible).toBe(false); + expect(Animated.spring).toHaveBeenCalledWith(instance.top, { + toValue: MIN_INPUT, + useNativeDriver: true, + }); + }); + }); + + describe('cancel', () => { + jest.useFakeTimers(); + + it('should set canceled, hide button, and disable viewable items handler when forced', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.componentDidUpdate = jest.fn(); + instance.canceled = false; + instance.hide = jest.fn(); + instance.disableViewableItems = false; + + instance.cancel(true); + expect(instance.canceled).toBe(true); + expect(instance.hide).toHaveBeenCalledTimes(1); + expect(instance.disableViewableItems).toBe(true); + }); + + it('should set canceled, hide button, and disable viewable items handler when not forced but indicator bar is not visible and posts are not loading', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.componentDidUpdate = jest.fn(); + instance.canceled = false; + instance.hide = jest.fn(); + instance.disableViewableItems = false; + instance.indicatorBarVisible = false; + wrapper.setProps({loadingPosts: false}); + + instance.cancel(false); + expect(instance.canceled).toBe(true); + expect(instance.hide).toHaveBeenCalledTimes(1); + expect(instance.disableViewableItems).toBe(true); + }); + + it('should delay cancel when not forced but indicator bar is visible', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.componentDidUpdate = jest.fn(); + instance.canceled = false; + instance.hide = jest.fn(); + instance.disableViewableItems = false; + instance.indicatorBarVisible = true; + instance.autoCancelTimer = null; + + instance.cancel(false); + expect(instance.canceled).toBe(false); + expect(instance.hide).not.toHaveBeenCalled(); + expect(instance.disableViewableItems).toBe(false); + expect(instance.autoCancelTimer).toBeDefined(); + expect(setTimeout).toHaveBeenCalledWith(instance.cancel, CANCEL_TIMER_DELAY); + + jest.runOnlyPendingTimers(); + }); + + it('should delay cancel when not forced and indicator bar is not visible but posts are loading', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.componentDidUpdate = jest.fn(); + instance.canceled = false; + instance.hide = jest.fn(); + instance.disableViewableItems = false; + instance.indicatorBarVisible = false; + instance.autoCancelTimer = null; + wrapper.setProps({loadingPosts: true}); + + instance.cancel(false); + expect(instance.canceled).toBe(false); + expect(instance.hide).not.toHaveBeenCalled(); + expect(instance.disableViewableItems).toBe(false); + expect(instance.autoCancelTimer).toBeDefined(); + expect(setTimeout).toHaveBeenCalledWith(instance.cancel, CANCEL_TIMER_DELAY); + + jest.runOnlyPendingTimers(); + }); + }); + + describe('uncancel', () => { + it('should set canceled and disableViewableItems to false and clear autoCancelTimer', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + const autoCancelTimer = jest.fn(); + instance.autoCancelTimer = autoCancelTimer; + instance.canceled = true; + instance.disableViewableItems = true; + + instance.uncancel(); + expect(clearTimeout).toHaveBeenCalledWith(autoCancelTimer); + expect(instance.autoCancelTimer).toBeNull(); + expect(instance.canceled).toBe(false); + expect(instance.disableViewableItems).toBe(false); + }); + }); + + describe('onMoreMessagesPress', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + + it('should return early when pressed is true', () => { + instance.pressed = true; + instance.onMoreMessagesPress(); + + expect(baseProps.scrollToIndex).not.toHaveBeenCalled(); + expect(instance.pressed).toBe(true); + }); + + it('should set pressed to true and scroll to the initial index', () => { + instance.pressed = false; + instance.onMoreMessagesPress(); + + expect(instance.pressed).toBe(true); + expect(baseProps.scrollToIndex).toHaveBeenCalledWith(baseProps.newMessageLineIndex); + }); + }); + + describe('onViewableItemsChanged', () => { + jest.useFakeTimers(); + + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.componentDidUpdate = jest.fn(); + instance.cancel = jest.fn(); + instance.getReadCount = jest.fn((count) => { + return count; + }); + instance.showMoreText = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + it('should return early when newMessageLineIndex <= 0', () => { + const viewableItems = [{index: 0}, {index: 1}]; + + wrapper.setProps({newMessageLineIndex: 0}); + instance.onViewableItemsChanged(viewableItems); + expect(clearTimeout).not.toHaveBeenCalled(); + + wrapper.setProps({newMessageLineIndex: -1}); + instance.onViewableItemsChanged(viewableItems); + expect(clearTimeout).not.toHaveBeenCalled(); + }); + + it('should return early when unreadCount is 0', () => { + const viewableItems = [{index: 0}, {index: 1}]; + + wrapper.setProps({newMessageLineIndex: 1, unreadCount: 0}); + instance.onViewableItemsChanged(viewableItems); + expect(clearTimeout).not.toHaveBeenCalled(); + + wrapper.setProps({newMessageLineIndex: -1}); + instance.onViewableItemsChanged(viewableItems); + expect(clearTimeout).not.toHaveBeenCalled(); + }); + + it('should return early when viewableItems length is 0', () => { + const viewableItems = []; + wrapper.setProps({newMessageLineIndex: 1, unreadCount: 10}); + + instance.onViewableItemsChanged(viewableItems); + expect(clearTimeout).not.toHaveBeenCalled(); + }); + + it('should return early when disableViewableItems is true', () => { + const viewableItems = [{index: 0}]; + wrapper.setProps({newMessageLineIndex: 1, unreadCount: 10}); + instance.disableViewableItems = true; + + instance.onViewableItemsChanged(viewableItems); + expect(clearTimeout).not.toHaveBeenCalled(); + }); + + it('should force cancel and also scroll to newMessageLineIndex when the channel is first loaded and the newMessageLineIndex is viewable', () => { + // When the channel is first loaded index 0 will be viewable + const viewableItems = [{index: 0}, {index: 1}, {index: 2}]; + const newMessageLineIndex = 2; + wrapper.setProps({newMessageLineIndex, unreadCount: 10}); + instance.disableViewableItems = false; + + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + + expect(instance.cancel).toHaveBeenCalledTimes(1); + expect(instance.cancel).toHaveBeenCalledWith(true); + expect(baseProps.scrollToIndex).toHaveBeenCalledWith(newMessageLineIndex); + }); + + it('should force cancel and also scroll to newMessageLineIndex when the channel is first loaded and the newMessageLineIndex will be the next viewable item', () => { + // When the channel is first loaded index 0 will be viewable + const viewableItems = [{index: 0}, {index: 1}, {index: 2}]; + const newMessageLineIndex = 3; + wrapper.setProps({newMessageLineIndex, unreadCount: 10}); + instance.disableViewableItems = false; + + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + + expect(instance.cancel).toHaveBeenCalledTimes(1); + expect(instance.cancel).toHaveBeenCalledWith(true); + expect(baseProps.scrollToIndex).toHaveBeenCalledWith(newMessageLineIndex); + }); + + it('should force cancel when the New Message line has been reached and there are no more unread messages', () => { + const viewableItems = [{index: 1}, {index: 2}, {index: 3}]; + const newMessageLineIndex = 3; + wrapper.setProps({newMessageLineIndex, unreadCount: newMessageLineIndex}); + instance.disableViewableItems = false; + instance.autoCancelTimer = null; + instance.cancel = jest.fn(); + + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + + expect(instance.cancel).toHaveBeenCalledWith(true); + expect(instance.autoCancelTimer).toBeNull(); + expect(setTimeout).not.toHaveBeenCalled(); + }); + + it('should set autoCancelTimer when the New Message line has been reached but there are still unread messages', () => { + const viewableItems = [{index: 1}, {index: 2}, {index: 3}]; + const newMessageLineIndex = 3; + wrapper.setProps({newMessageLineIndex, unreadCount: newMessageLineIndex + 1}); + instance.disableViewableItems = false; + instance.autoCancelTimer = null; + instance.cancel = jest.fn(); + + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + + expect(instance.autoCancelTimer).toBeDefined(); + expect(setTimeout).toHaveBeenCalledWith(instance.cancel, CANCEL_TIMER_DELAY); + }); + + it('should call showMoreText with the read count derived from endIndex when endIndex is reached', () => { + const viewableItems = [{index: 1}, {index: 2}, {index: 3}]; + const newMessageLineIndex = 10; + wrapper.setProps({newMessageLineIndex}); + wrapper.setState({moreText: '10 new messages'}); + instance.disableViewableItems = false; + + instance.endIndex = null; + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + expect(instance.getReadCount).not.toHaveBeenCalled(); + expect(instance.showMoreText).not.toHaveBeenCalled(); + expect(instance.endIndex).toBeNull(); + + instance.endIndex = 4; + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + expect(instance.getReadCount).not.toHaveBeenCalled(); + expect(instance.showMoreText).not.toHaveBeenCalled(); + expect(instance.endIndex).toEqual(4); + + instance.endIndex = 3; + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + expect(instance.getReadCount).toHaveBeenCalledWith(3); + expect(instance.showMoreText).toHaveBeenCalledWith(3); + expect(instance.endIndex).toBeNull(); + }); + + it('should call showMoreText with 0 when moreText is empty', () => { + const viewableItems = [{index: 1}, {index: 2}, {index: 3}]; + const newMessageLineIndex = 10; + wrapper.setProps({newMessageLineIndex}); + instance.disableViewableItems = false; + instance.endIndex = null; + + wrapper.setState({moreText: '1 new message'}); + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + expect(instance.showMoreText).not.toHaveBeenCalled(); + + wrapper.setState({moreText: ''}); + instance.onViewableItemsChanged(viewableItems); + jest.runOnlyPendingTimers(); + expect(instance.showMoreText).toHaveBeenCalledWith(0); + }); + }); + + describe('showMoreText', () => { + const props = { + ...baseProps, + unreadCount: 10, + }; + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + instance.cancel = jest.fn(); + instance.moreText = jest.fn((text) => { + return text; + }); + instance.setState = jest.fn(); + instance.show = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should force cancel when props.unreadCount - readCount is <= 0', () => { + let readCount = props.unreadCount + 1; + instance.showMoreText(readCount); + expect(instance.cancel).toHaveBeenCalledTimes(1); + expect(instance.cancel.mock.calls[0][0]).toBe(true); + expect(instance.moreText).not.toHaveBeenCalled(); + expect(instance.setState).not.toHaveBeenCalled(); + + readCount = props.unreadCount; + instance.showMoreText(readCount); + expect(instance.cancel).toHaveBeenCalledTimes(2); + expect(instance.cancel.mock.calls[1][0]).toBe(true); + }); + + it('should set moreTextd when props.unreadCount - readCount is > 0', () => { + const moreCount = 1; + const readCount = props.unreadCount - moreCount; + instance.showMoreText(readCount); + expect(instance.cancel).not.toHaveBeenCalled(); + expect(instance.moreText).toHaveBeenCalledWith(moreCount); + expect(instance.setState).toHaveBeenCalledWith({moreText: moreCount}, instance.show); + }); + }); + + describe('getReadCount', () => { + PostListUtils.messageCount = jest.fn().mockImplementation((postIds) => postIds.length); // eslint-disable-line no-import-assign + + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + + it('should return the count of posts up to the lastViewableIndex', () => { + const postIds = ['post1', 'post2', 'post3']; + wrapper.setProps({postIds}); + + let lastViewableIndex = 0; + let readCount = instance.getReadCount(lastViewableIndex); + expect(readCount).toEqual(1); + + lastViewableIndex = 1; + readCount = instance.getReadCount(lastViewableIndex); + expect(readCount).toEqual(2); + + lastViewableIndex = 2; + readCount = instance.getReadCount(lastViewableIndex); + expect(readCount).toEqual(3); + + lastViewableIndex = 3; + readCount = instance.getReadCount(lastViewableIndex); + expect(readCount).toEqual(3); + }); + }); + + describe('onScrollEndIndex', () => { + it('should set pressed to false', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + + instance.pressed = true; + instance.onScrollEndIndex(); + expect(instance.pressed).toBe(false); + }); + }); + + describe('moreText', () => { + const wrapper = shallowWithIntl( + , + ); + const instance = wrapper.instance(); + + it('should return defaultMessage of `{count} new messages` when count is > 1 and count equals unreadCount', () => { + let moreCount = 2; + wrapper.setProps({unreadCount: moreCount}); + let message = instance.moreText(moreCount); + expect(message).toEqual('2 new messages'); + + moreCount = 3; + wrapper.setProps({unreadCount: moreCount}); + message = instance.moreText(moreCount); + expect(message).toEqual('3 new messages'); + }); + + it('should return defaultMessage of `{count} more new messages` when count > 1 and count does not equal unreadCount', () => { + let moreCount = 2; + wrapper.setProps({unreadCount: 10}); + let message = instance.moreText(moreCount); + expect(message).toEqual('2 more new messages'); + + moreCount = 3; + message = instance.moreText(moreCount); + expect(message).toEqual('3 more new messages'); + }); + + it('should return defaultMessage of `1 new message` when count === 1 and count equals unreadCount', () => { + const moreCount = 1; + wrapper.setProps({unreadCount: 1}); + const message = instance.moreText(moreCount); + expect(message).toEqual('1 new message'); + }); + + it('should return defaultMessage of `1 more new message` when count === 1 and count does not equal unreadCount', () => { + const moreCount = 1; + wrapper.setProps({unreadCount: 10}); + const message = instance.moreText(moreCount); + expect(message).toEqual('1 more new message'); + }); + }); +}); diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index ebc2af8684..ab9fd31733 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -3,9 +3,10 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Alert, FlatList, InteractionManager, Platform, RefreshControl, StyleSheet} from 'react-native'; +import {Alert, FlatList, Platform, RefreshControl, StyleSheet} from 'react-native'; import {intlShape} from 'react-intl'; +import {Posts} from '@mm-redux/constants'; import EventEmitter from '@mm-redux/utils/event_emitter'; import * as PostListUtils from '@mm-redux/utils/post_list'; @@ -23,6 +24,7 @@ import {t} from 'app/utils/i18n'; import DateHeader from './date_header'; import NewMessagesDivider from './new_messages_divider'; +import MoreMessagesButton from './more_messages_button'; const INITIAL_BATCH_TO_RENDER = 10; const SCROLL_UP_MULTIPLIER = 3.5; @@ -71,6 +73,7 @@ export default class PostList extends PureComponent { theme: PropTypes.object.isRequired, location: PropTypes.string, scrollViewNativeID: PropTypes.string, + showMoreMessagesButton: PropTypes.bool, }; static defaultProps = { @@ -80,6 +83,7 @@ export default class PostList extends PureComponent { serverURL: '', siteURL: '', postIds: [], + showMoreMessagesButton: false, }; static contextTypes = { @@ -89,12 +93,10 @@ export default class PostList extends PureComponent { constructor(props) { super(props); - this.cancelScrollToIndex = false; this.contentOffsetY = 0; this.contentHeight = 0; this.hasDoneInitialScroll = false; this.shouldScrollToBottom = false; - this.cancelScrollToIndex = false; this.makeExtraData = makeExtraData(); this.flatListRef = React.createRef(); } @@ -111,7 +113,7 @@ export default class PostList extends PureComponent { } // Scroll to highlighted post for permalinks - if (!this.hasDoneInitialScroll && initialIndex > 0 && !this.cancelScrollToIndex && highlightPostId) { + if (!this.hasDoneInitialScroll && initialIndex > 0 && highlightPostId) { this.scrollToInitialIndexIfNeeded(initialIndex); } } @@ -134,10 +136,6 @@ export default class PostList extends PureComponent { this.shouldScrollToBottom = false; } - if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && !this.cancelScrollToIndex) { - this.scrollToInitialIndexIfNeeded(this.props.initialIndex); - } - if ( this.props.channelId === prevProps.channelId && this.props.postIds.length && @@ -157,14 +155,12 @@ export default class PostList extends PureComponent { flatListScrollToIndex = (index) => { this.animationFrameInitialIndex = requestAnimationFrame(() => { - if (!this.cancelScrollToIndex) { - this.flatListRef.current.scrollToIndex({ - animated: false, - index, - viewOffset: 0, - viewPosition: 1, // 0 is at bottom - }); - } + this.flatListRef.current.scrollToIndex({ + animated: true, + index, + viewOffset: 0, + viewPosition: 1, // 0 is at bottom + }); }); } @@ -264,29 +260,15 @@ export default class PostList extends PureComponent { } }; - handleScrollBeginDrag = () => { - this.cancelScrollToIndex = true; - } - handleScrollToIndexFailed = (info) => { this.animationFrameIndexFailed = requestAnimationFrame(() => { - if (this.props.initialIndex > 0 && this.contentHeight > 0) { - this.hasDoneInitialScroll = false; - if (info.highestMeasuredFrameIndex) { - this.scrollToInitialIndexIfNeeded(info.highestMeasuredFrameIndex); - } else { - this.scrollAfterInteraction = InteractionManager.runAfterInteractions(() => { - this.scrollToInitialIndexIfNeeded(info.index); - }); - } + if (this.onScrollEndIndexListener) { + this.onScrollEndIndexListener(info.highestMeasuredFrameIndex); } + this.flatListScrollToIndex(info.highestMeasuredFrameIndex); }); }; - handleScrollBeginDrag = () => { - this.cancelScrollToIndex = true; - }; - handleSetScrollToBottom = () => { this.shouldScrollToBottom = true; } @@ -394,9 +376,8 @@ export default class PostList extends PureComponent { } const postId = item; - return ( - { this.contentOffsetY = 0; this.hasDoneInitialScroll = false; - this.cancelScrollToIndex = false; if (this.scrollAfterInteraction) { this.scrollAfterInteraction.cancel(); @@ -460,12 +440,11 @@ export default class PostList extends PureComponent { if (index > 0 && index <= this.getItemCount()) { this.hasDoneInitialScroll = true; this.scrollToIndex(index); - if (index !== this.props.initialIndex) { this.hasDoneInitialScroll = false; this.scrollToInitialTimer = setTimeout(() => { this.scrollToInitialIndexIfNeeded(this.props.initialIndex); - }); + }, 10); } } } @@ -494,6 +473,32 @@ export default class PostList extends PureComponent { } }; + registerViewableItemsListener = (listener) => { + this.onViewableItemsChangedListener = listener; + const removeListener = () => { + this.onViewableItemsChangedListener = null; + }; + + return removeListener; + } + + registerScrollEndIndexListener = (listener) => { + this.onScrollEndIndexListener = listener; + const removeListener = () => { + this.onScrollEndIndexListener = null; + }; + + return removeListener; + } + + onViewableItemsChanged = ({viewableItems}) => { + if (!this.onViewableItemsChangedListener || !viewableItems.length || this.props.deepLinkURL) { + return; + } + + this.onViewableItemsChangedListener(viewableItems); + } + render() { const { channelId, @@ -504,6 +509,9 @@ export default class PostList extends PureComponent { refreshing, scrollViewNativeID, theme, + initialIndex, + deepLinkURL, + showMoreMessagesButton, } = this.props; const refreshControl = ( @@ -515,39 +523,78 @@ export default class PostList extends PureComponent { />); const hasPostsKey = postIds.length ? 'true' : 'false'; + return ( - + <> + + {showMoreMessagesButton && + + } + ); } } +function PostComponent({postId, highlightPostId, lastPostIndex, index, ...postProps}) { + return ( + + ); +} + +PostComponent.propTypes = { + postId: PropTypes.string.isRequired, + highlightPostId: PropTypes.string, + lastPostIndex: PropTypes.number, + index: PropTypes.number, +}; + +const MemoizedPost = React.memo(PostComponent); + const styles = StyleSheet.create({ flex: { flex: 1, diff --git a/app/components/retry_bar_indicator/__snapshots__/retry_bar_indicator.test.js.snap b/app/components/retry_bar_indicator/__snapshots__/retry_bar_indicator.test.js.snap new file mode 100644 index 0000000000..7877ce5277 --- /dev/null +++ b/app/components/retry_bar_indicator/__snapshots__/retry_bar_indicator.test.js.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RetryBarIndicator should match snapshot 1`] = ` + + + +`; diff --git a/app/components/retry_bar_indicator/retry_bar_indicator.js b/app/components/retry_bar_indicator/retry_bar_indicator.js index 168290db3a..a9b35f28d1 100644 --- a/app/components/retry_bar_indicator/retry_bar_indicator.js +++ b/app/components/retry_bar_indicator/retry_bar_indicator.js @@ -6,7 +6,13 @@ import { Animated, StyleSheet, } from 'react-native'; -import FormattedText from 'app/components/formatted_text'; + +import EventEmitter from '@mm-redux/utils/event_emitter'; + +import {ViewTypes} from '@constants'; +import {INDICATOR_BAR_HEIGHT} from '@constants/view'; + +import FormattedText from '@components/formatted_text'; const {View: AnimatedView} = Animated; @@ -26,12 +32,21 @@ export default class RetryBarIndicator extends PureComponent { } toggleRetryMessage = (show = true) => { - const value = show ? 38 : 0; + const value = show ? INDICATOR_BAR_HEIGHT : 0; + + if (show) { + EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, true); + } + Animated.timing(this.state.retryMessageHeight, { toValue: value, duration: 350, useNativeDriver: false, - }).start(); + }).start(() => { + if (!show) { + EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, false); + } + }); }; render() { diff --git a/app/components/retry_bar_indicator/retry_bar_indicator.test.js b/app/components/retry_bar_indicator/retry_bar_indicator.test.js new file mode 100644 index 0000000000..ef825fc5c4 --- /dev/null +++ b/app/components/retry_bar_indicator/retry_bar_indicator.test.js @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Animated} from 'react-native'; +import {shallow} from 'enzyme'; + +import EventEmitter from '@mm-redux/utils/event_emitter'; + +import ViewTypes from '@constants/view'; + +import RetryBarIndicator from './retry_bar_indicator.js'; + +describe('RetryBarIndicator', () => { + it('should match snapshot', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + describe('toggleRetryMessage', () => { + Animated.timing = jest.fn(() => ({ + start: jest.fn((cb) => { + cb(); + }), + })); + EventEmitter.emit = jest.fn(); + + const wrapper = shallow( + , + ); + const instance = wrapper.instance(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should emit INDICATOR_BAR_VISIBLE with true when show is true', () => { + const show = true; + instance.toggleRetryMessage(show); + expect(EventEmitter.emit).toHaveBeenCalledTimes(1); + expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, true); + expect(Animated.timing).toHaveBeenCalledTimes(1); + }); + + it('should emit INDICATOR_BAR_VISIBLE with false when show is false', () => { + const show = false; + instance.toggleRetryMessage(show); + expect(EventEmitter.emit).toHaveBeenCalledTimes(1); + expect(EventEmitter.emit).toHaveBeenCalledWith(ViewTypes.INDICATOR_BAR_VISIBLE, false); + expect(Animated.timing).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/app/constants/view.js b/app/constants/view.js index d725162238..1d52b1b1e2 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -1,9 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import keyMirror from '@mm-redux/utils/key_mirror'; +import {Platform} from 'react-native'; import DeviceInfo from 'react-native-device-info'; +import keyMirror from '@mm-redux/utils/key_mirror'; + // The iPhone 11 and iPhone 11 Pro Max have a navbar height of 44 and iPhone 11 Pro has 32 const IPHONE_11_LANDSCAPE_HEIGHT = ['iPhone 11', 'iPhone 11 Pro Max']; @@ -32,6 +34,7 @@ export const NotificationLevels = { }; export const NOTIFY_ALL_MEMBERS = 5; +export const INDICATOR_BAR_HEIGHT = 38; const ViewTypes = keyMirror({ DATA_CLEANUP: null, @@ -93,6 +96,8 @@ const ViewTypes = keyMirror({ PORTRAIT: null, LANDSCAPE: null, + + INDICATOR_BAR_VISIBLE: null, }); const RequiredServer = { @@ -105,7 +110,7 @@ const RequiredServer = { export default { ...ViewTypes, RequiredServer, - POST_VISIBILITY_CHUNK_SIZE: 15, + POST_VISIBILITY_CHUNK_SIZE: Platform.OS === 'android' ? 15 : 60, FEATURE_TOGGLE_PREFIX: 'feature_enabled_', EMBED_PREVIEW: 'embed_preview', LINK_PREVIEW_DISPLAY: 'link_previews', diff --git a/app/mm-redux/action_types/channels.ts b/app/mm-redux/action_types/channels.ts index 08db9cacf5..0778fdb4aa 100644 --- a/app/mm-redux/action_types/channels.ts +++ b/app/mm-redux/action_types/channels.ts @@ -67,6 +67,7 @@ export default keyMirror({ INCREMENT_TOTAL_MSG_COUNT: null, INCREMENT_UNREAD_MSG_COUNT: null, DECREMENT_UNREAD_MSG_COUNT: null, + SET_UNREAD_MSG_COUNT: null, INCREMENT_UNREAD_MENTION_COUNT: null, DECREMENT_UNREAD_MENTION_COUNT: null, diff --git a/app/mm-redux/actions/posts.ts b/app/mm-redux/actions/posts.ts index b1047f7b36..e31fba0fb3 100644 --- a/app/mm-redux/actions/posts.ts +++ b/app/mm-redux/actions/posts.ts @@ -182,6 +182,7 @@ export function createPost(post: Post, files: any[] = []) { pending_post_id: pendingPostId, create_at: timestamp, update_at: timestamp, + ownPost: true, }; // We are retrying a pending post that had files @@ -218,10 +219,7 @@ export function createPost(post: Post, files: any[] = []) { const created = await Client4.createPost({...newPost, create_at: 0}); actions = [ - receivedPost(created), - { - type: PostTypes.CREATE_POST_SUCCESS, - }, + receivedPost({...created, ownPost: true}), { type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT, data: { @@ -287,6 +285,7 @@ export function createPostImmediately(post: Post, files: any[] = []) { pending_post_id: pendingPostId, create_at: timestamp, update_at: timestamp, + ownPost: true, }; if (files.length) { @@ -304,14 +303,43 @@ export function createPostImmediately(post: Post, files: any[] = []) { }); } - dispatch(receivedNewPost({ - ...newPost, - id: pendingPostId, - })); + dispatch( + receivedNewPost({ + ...newPost, + id: pendingPostId, + }), + ); try { const created = await Client4.createPost({...newPost, create_at: 0}); - newPost.id = created.id; + + const actions: Action[] = [ + receivedPost({...created, ownPost: true}), + { + type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT, + data: { + channelId: newPost.channel_id, + amount: 1, + }, + }, + { + type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT, + data: { + channelId: newPost.channel_id, + amount: 1, + }, + }, + ]; + + if (files) { + actions.push({ + type: FileTypes.RECEIVED_FILES_FOR_POST, + postId: newPost.id, + data: files, + }); + } + + dispatch(batchActions(actions)); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(batchActions([ @@ -322,37 +350,6 @@ export function createPostImmediately(post: Post, files: any[] = []) { return {error}; } - const actions: Action[] = [ - receivedPost(newPost), - { - type: PostTypes.CREATE_POST_SUCCESS, - }, - { - type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT, - data: { - channelId: newPost.channel_id, - amount: 1, - }, - }, - { - type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT, - data: { - channelId: newPost.channel_id, - amount: 1, - }, - }, - ]; - - if (files) { - actions.push({ - type: FileTypes.RECEIVED_FILES_FOR_POST, - postId: newPost.id, - data: files, - }); - } - - dispatch(batchActions(actions)); - return {data: newPost}; }; } diff --git a/app/mm-redux/types/posts.ts b/app/mm-redux/types/posts.ts index 76cf07b49d..27f53afa2c 100644 --- a/app/mm-redux/types/posts.ts +++ b/app/mm-redux/types/posts.ts @@ -70,6 +70,7 @@ export type Post = { failed?: boolean; user_activity_posts?: Array; state?: 'DELETED'; + ownPost?: boolean; }; export type PostWithFormatData = Post & { diff --git a/app/mm-redux/utils/post_list.test.js b/app/mm-redux/utils/post_list.test.js index 5decd08303..a329794b42 100644 --- a/app/mm-redux/utils/post_list.test.js +++ b/app/mm-redux/utils/post_list.test.js @@ -7,11 +7,11 @@ import {Posts, Preferences} from '../constants'; import deepFreeze from '@mm-redux/utils/deep_freeze'; import {getPreferenceKey} from '@mm-redux/utils/preference_utils'; +import TestHelper from 'test/test_helper'; + import { - COMBINED_USER_ACTIVITY, combineUserActivitySystemPost, comparePostTypes, - DATE_LINE, getDateForDateLine, getFirstPostId, getLastPostId, @@ -23,7 +23,10 @@ import { makeFilterPostsAndAddSeparators, makeGenerateCombinedPost, postTypePriority, + messageCount, START_OF_NEW_MESSAGES, + COMBINED_USER_ACTIVITY, + DATE_LINE, } from './post_list'; describe('makeFilterPostsAndAddSeparators', () => { @@ -1702,3 +1705,25 @@ describe('comparePostTypes', () => { } }); }); + +describe('messageCount', () => { + it('should return the count of message post IDs', () => { + const postIds = []; + expect(messageCount(postIds)).toEqual(0); + + postIds.push(START_OF_NEW_MESSAGES); + expect(messageCount(postIds)).toEqual(0); + + postIds.push(DATE_LINE); + expect(messageCount(postIds)).toEqual(0); + + postIds.push(COMBINED_USER_ACTIVITY + TestHelper.generateId()); + expect(messageCount(postIds)).toEqual(0); + + postIds.push(TestHelper.generateId()); + expect(messageCount(postIds)).toEqual(1); + + postIds.push(TestHelper.generateId()); + expect(messageCount(postIds)).toEqual(2); + }); +}); diff --git a/app/mm-redux/utils/post_list.ts b/app/mm-redux/utils/post_list.ts index 628de23e4f..bfa627416c 100644 --- a/app/mm-redux/utils/post_list.ts +++ b/app/mm-redux/utils/post_list.ts @@ -101,7 +101,6 @@ export function makeFilterPostsAndAddSeparators() { if ( lastViewedAt && post.create_at > lastViewedAt && - post.user_id !== currentUser.id && !addedNewMessagesIndicator && indicateNewMessages ) { @@ -433,3 +432,11 @@ export function combineUserActivitySystemPost(systemPosts: Array { + return !isStartOfNewMessages(postId) && !isDateLine(postId) && !isCombinedUserActivityPost(postId); + }); + + return messagePostIds.length; +} diff --git a/app/reducers/views/channel.js b/app/reducers/views/channel.js index cc4dae9973..14430b7b8b 100644 --- a/app/reducers/views/channel.js +++ b/app/reducers/views/channel.js @@ -313,6 +313,16 @@ function lastChannelViewTime(state = {}, action) { return {...state, [data.channelId]: data.lastViewedAt}; } + case PostTypes.RECEIVED_POST: + case PostTypes.RECEIVED_NEW_POST: { + const data = action.data; + if (!data.ownPost) { + return state; + } + + return {...state, [data.channel_id]: data.create_at + 1}; + } + default: return state; } @@ -359,6 +369,20 @@ function keepChannelIdAsUnread(state = null, action) { } } +function unreadMessageCount(state = {}, action) { + switch (action.type) { + case ChannelTypes.SET_UNREAD_MSG_COUNT: { + const {channelId, count} = action.data; + return { + ...state, + [channelId]: count, + }; + } + default: + return state; + } +} + export default combineReducers({ displayName, drafts, @@ -370,4 +394,5 @@ export default combineReducers({ loadMorePostsVisible, lastChannelViewTime, keepChannelIdAsUnread, + unreadMessageCount, }); diff --git a/app/reducers/views/channel.test.js b/app/reducers/views/channel.test.js index d2ae8b7247..88940a0572 100644 --- a/app/reducers/views/channel.test.js +++ b/app/reducers/views/channel.test.js @@ -15,6 +15,7 @@ describe('Reducers.channel', () => { loadMorePostsVisible: true, lastChannelViewTime: {}, keepChannelIdAsUnread: null, + unreadMessageCount: {}, }; test('Initial state', () => { @@ -30,6 +31,7 @@ describe('Reducers.channel', () => { loadMorePostsVisible: true, lastChannelViewTime: {}, keepChannelIdAsUnread: null, + unreadMessageCount: {}, }, {}, ); diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index 19f68da4b1..a6e8e82105 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -34,6 +34,7 @@ export default class ChannelBase extends PureComponent { selectDefaultTeam: PropTypes.func.isRequired, selectInitialChannel: PropTypes.func.isRequired, recordLoadTime: PropTypes.func.isRequired, + resetUnreadMessageCount: PropTypes.func.isRequired, }).isRequired, componentId: PropTypes.string.isRequired, currentChannelId: PropTypes.string, @@ -89,6 +90,7 @@ export default class ChannelBase extends PureComponent { } if (currentChannelId) { + actions.resetUnreadMessageCount(this.props.currentChannelId); PushNotifications.clearChannelNotifications(currentChannelId); requestAnimationFrame(() => { actions.getChannelStats(currentChannelId); diff --git a/app/screens/channel/channel_base.test.js b/app/screens/channel/channel_base.test.js index c2b22b9434..a1787068f3 100644 --- a/app/screens/channel/channel_base.test.js +++ b/app/screens/channel/channel_base.test.js @@ -27,6 +27,7 @@ describe('ChannelBase', () => { recordLoadTime: jest.fn(), selectDefaultTeam: jest.fn(), selectInitialChannel: jest.fn(), + resetUnreadMessageCount: jest.fn(), }, componentId: channelBaseComponentId, theme: Preferences.THEMES.default, diff --git a/app/screens/channel/channel_post_list/channel_post_list.js b/app/screens/channel/channel_post_list/channel_post_list.js index 3fbe4b89cd..813f41bfac 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.js +++ b/app/screens/channel/channel_post_list/channel_post_list.js @@ -190,6 +190,7 @@ export default class ChannelPostList extends PureComponent { refreshing={refreshing} scrollViewNativeID={channelId} loadMorePostsVisible={this.props.loadMorePostsVisible} + showMoreMessagesButton={true} /> ); } diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index 27ba47077b..d55ae86604 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -4,7 +4,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {loadChannelsForTeam, selectInitialChannel} from '@actions/views/channel'; +import {loadChannelsForTeam, selectInitialChannel, resetUnreadMessageCount} from '@actions/views/channel'; import {recordLoadTime} from '@actions/views/root'; import {selectDefaultTeam} from '@actions/views/select_team'; import {ViewTypes} from '@constants'; @@ -52,6 +52,7 @@ function mapDispatchToProps(dispatch) { selectDefaultTeam, selectInitialChannel, recordLoadTime, + resetUnreadMessageCount, }, dispatch), }; } diff --git a/app/utils/theme.js b/app/utils/theme.js index 8c3a4d9a9f..08e695dda8 100644 --- a/app/utils/theme.js +++ b/app/utils/theme.js @@ -72,3 +72,33 @@ export function isThemeSwitchingEnabled(state) { export function getKeyboardAppearanceFromTheme(theme) { return tinyColor(theme.centerChannelBg).isLight() ? 'light' : 'dark'; } + +export function hexToHue(hexColor) { + let {red, green, blue} = ThemeUtils.getComponents(hexColor); + red /= 255; + green /= 255; + blue /= 255; + + const channelMax = Math.max(red, green, blue); + const channelMin = Math.min(red, green, blue); + const delta = channelMax - channelMin; + let hue = 0; + + if (delta === 0) { + hue = 0; + } else if (channelMax === red) { + hue = ((green - blue) / delta) % 6; + } else if (channelMax === green) { + hue = ((blue - red) / delta) + 2; + } else { + hue = ((red - green) / delta) + 4; + } + + hue = Math.round(hue * 60); + + if (hue < 0) { + hue += 360; + } + + return hue; +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index ff367c690b..f9ffce2af9 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -309,6 +309,7 @@ "mobile.more_dms.start": "Start", "mobile.more_dms.title": "New Conversation", "mobile.more_dms.you": "@{username} - you", + "mobile.more_messages_button.text": "{count} {isInitialMessage, select, true {new} other {more new}} {count, plural, one {message} other {messages}}", "mobile.notice_mobile_link": "mobile apps", "mobile.notice_platform_link": "server", "mobile.notice_text": "Mattermost is made possible by the open source software used in our {platform} and {mobile}.",