[MM-23761] [MM-25766] Add "More Messages" button (#4526)

* Add more messages button

* Update existing tests

* Various fixes:

* Use viewAreaCoveragePercentThreshold over itemVisiblePercentThreshold
* Remove currentUserId check when adding New Message line to postIds and
  instead update the channel last viewed time when an own post is received

* Update snapshots

* Add showMoreMessagesButton prop and default to false

* Android fixes

* Add tests

* Localize more messages text

* Use FormattedText

* i18 extract

* Style fixes

* Account for network indicator

* Fix for failing tests

* Various fixes:

* Set the unreadMessageCount when POST_UNREAD_SUCCESS is dispatched
  with a positive deltaMsgs
* Hide the more messages button when the unread count decreases or
  when the new message line is removed shortly after loading the
  channel

* No need for POST_UNREAD_SUCCESS if we manually call onViewableItemsChanged

* Reset unread count if current channel on channel mount

* Animate text opacity

* Compare indeces to determine when scrolling has ended

* Fix opacity animation trigger

* try with scrolling to the last rendered item

* Add onScrollEndIndex

* Improve animations

* Don't track moreCount in state

* Use moreText over prevNewMessageLineIndex to determine firstPage

* Update intl message format and call cancel in componentDidUpdate when needed

* Fix intl format

* Remove opacity animation and countText

* Fix pressed not being reset

* No need to separate intl func

* Return after resetting

* Fix accidental removal of setState call

* Reset pressed when newLineMessageIndex changes

* Use default windowSize and lower POST_CHUNK_SIZE and delays

* Queue a cancel timer that gets cleared only when the newMessageLineIndex changes

* Define uncancel func

* Increase cancelTimer delay

* Subtract read posts from unread count and account for retry indicator

* Add retry bar indicator tests

* Use props.unreadCount

* Fix handling of newMessageLineIndex change

* Fix handling of newMessageLineIndex change take 2

* Fix handling of newMessageLineIndex change take 3

* Use 'native' TouchableWithFeedback with dark overlay

* Fix handling of manually unread

* Update chunk and window sizes

* Fix hsl

* Update text only when newMessageLineIndex/endIndex is reached

* Don't delay cancel if when no more unreads

* Fixes for when opening the app

* No need to process viewableItems when unreadCount is 0

* Remove line

* Don't show if unreadCount is 0

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Miguel Alatzar
2020-07-09 13:30:30 -07:00
committed by GitHub
parent 23758fe491
commit f79f9dc697
28 changed files with 2211 additions and 354 deletions

View File

@@ -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,
},
});
};
}

View File

@@ -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<GenericAction> = [];
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);
}

View File

@@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AttachmentFooter matches snapshot 1`] = `
<ForwardRef(AnimatedComponentWrapper)
pointerEvents="none"
style={
Array [
Object {
"height": 38,
"position": "absolute",
"width": "100%",
"zIndex": 9,
},
Object {
"backgroundColor": "rgba(147, 147, 147, 1)",
"opacity": 0,
"top": 50,
},
]
}
>
<ForwardRef(AnimatedComponentWrapper)
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 38,
"paddingLeft": 12,
"paddingRight": 5,
}
}
>
<FormattedText
defaultMessage="Connected"
id="mobile.offlineIndicator.connected"
style={
Object {
"color": "#FFFFFF",
"flex": 1,
"fontSize": 12,
"fontWeight": "600",
}
}
/>
<View
style={
Object {
"alignItems": "flex-end",
"height": 24,
"justifyContent": "center",
"paddingRight": 10,
"width": 60,
}
}
>
<Icon
allowFontScaling={false}
color="#FFFFFF"
name="md-checkmark"
size={20}
/>
</View>
</ForwardRef(AnimatedComponentWrapper)>
</ForwardRef(AnimatedComponentWrapper)>
`;

View File

@@ -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,

View File

@@ -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(<NetworkIndicator {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
describe('show', () => {
EventEmitter.emit = jest.fn();
const wrapper = shallow(<NetworkIndicator {...baseProps}/>);
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(<NetworkIndicator {...baseProps}/>);
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);
});
});
});

View File

@@ -1,208 +1,232 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostList setting channel deep link 1`] = `
<FlatList
ListFooterComponent={[Function]}
contentContainerStyle={
Object {
"paddingTop": 5,
}
}
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]}
onScrollBeginDrag={[Function]}
onScrollToIndexFailed={[Function]}
refreshControl={
<RefreshControl
colors={
Array [
"#3d3c40",
]
<React.Fragment>
<FlatList
ListFooterComponent={[Function]}
contentContainerStyle={
Object {
"paddingTop": 5,
}
onRefresh={[Function]}
refreshing={false}
tintColor="#3d3c40"
/>
}
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={
<RefreshControl
colors={
Array [
"#3d3c40",
]
}
onRefresh={[Function]}
refreshing={false}
tintColor="#3d3c40"
/>
}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={60}
style={
Object {
"flex": 1,
}
}
updateCellsBatchingPeriod={50}
viewabilityConfig={
Object {
"itemVisiblePercentThreshold": 1,
"minimumViewTime": 100,
}
}
windowSize={30}
/>
</React.Fragment>
`;
exports[`PostList setting permalink deep link 1`] = `
<FlatList
ListFooterComponent={[Function]}
contentContainerStyle={
Object {
"paddingTop": 5,
}
}
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]}
onScrollBeginDrag={[Function]}
onScrollToIndexFailed={[Function]}
refreshControl={
<RefreshControl
colors={
Array [
"#3d3c40",
]
<React.Fragment>
<FlatList
ListFooterComponent={[Function]}
contentContainerStyle={
Object {
"paddingTop": 5,
}
onRefresh={[Function]}
refreshing={false}
tintColor="#3d3c40"
/>
}
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={
<RefreshControl
colors={
Array [
"#3d3c40",
]
}
onRefresh={[Function]}
refreshing={false}
tintColor="#3d3c40"
/>
}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={60}
style={
Object {
"flex": 1,
}
}
updateCellsBatchingPeriod={50}
viewabilityConfig={
Object {
"itemVisiblePercentThreshold": 1,
"minimumViewTime": 100,
}
}
windowSize={30}
/>
</React.Fragment>
`;
exports[`PostList should match snapshot 1`] = `
<FlatList
ListFooterComponent={[Function]}
contentContainerStyle={
Object {
"paddingTop": 5,
}
}
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]}
onScrollBeginDrag={[Function]}
onScrollToIndexFailed={[Function]}
refreshControl={
<RefreshControl
colors={
Array [
"#3d3c40",
]
<React.Fragment>
<FlatList
ListFooterComponent={[Function]}
contentContainerStyle={
Object {
"paddingTop": 5,
}
onRefresh={[Function]}
refreshing={false}
tintColor="#3d3c40"
/>
}
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={
<RefreshControl
colors={
Array [
"#3d3c40",
]
}
onRefresh={[Function]}
refreshing={false}
tintColor="#3d3c40"
/>
}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={60}
style={
Object {
"flex": 1,
}
}
updateCellsBatchingPeriod={50}
viewabilityConfig={
Object {
"itemVisiblePercentThreshold": 1,
"minimumViewTime": 100,
}
}
windowSize={30}
/>
</React.Fragment>
`;

View File

@@ -0,0 +1,139 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MoreMessagesButton should match snapshot 1`] = `
<ForwardRef(AnimatedComponentWrapper)
style={
Array [
Object {
"backgroundColor": "#166de0",
"elevation": 1,
"flex": 1,
"margin": 8,
"position": "absolute",
"zIndex": 1,
},
Object {
"borderRadius": 4,
},
Object {
"transform": Array [
Object {
"translateY": -138,
},
],
},
]
}
>
<TouchableWithFeedbackIOS
onPress={[Function]}
style={
Object {
"borderRadius": 4,
}
}
type="native"
underlayColor="hsl(214, 50%, 38%)"
>
<View
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"height": 40,
"justifyContent": "space-evenly",
"paddingLeft": 11,
"paddingRight": 5,
"paddingVertical": 1,
"shadowColor": "#3d3c40",
"shadowOffset": Object {
"height": 6,
"width": 0,
},
"shadowOpacity": 0.12,
"shadowRadius": 4,
"width": "100%",
},
Object {
"borderRadius": 4,
},
]
}
>
<View
style={
Object {
"width": 22,
}
}
>
<VectorIcon
name="md-arrow-up"
size={14}
style={
Object {
"alignSelf": "center",
"color": "#ffffff",
"fontSize": 18,
"fontWeight": "bold",
}
}
type="ion"
/>
</View>
<View
style={
Object {
"alignItems": "flex-start",
"flex": 10,
"flexDirection": "row",
"paddingLeft": 4,
}
}
>
<Text
style={
Object {
"alignSelf": "center",
"color": "#ffffff",
"fontWeight": "bold",
"paddingLeft": 0,
}
}
>
</Text>
</View>
<TouchableWithFeedbackIOS
onPress={[Function]}
type="none"
>
<View
style={
Object {
"alignItems": "flex-end",
"flex": 1,
}
}
>
<VectorIcon
name="md-close"
size={14}
style={
Object {
"alignSelf": "center",
"color": "#ffffff",
"fontSize": 18,
"fontWeight": "bold",
}
}
type="ion"
/>
</View>
</TouchableWithFeedbackIOS>
</View>
</TouchableWithFeedbackIOS>
</ForwardRef(AnimatedComponentWrapper)>
`;

View File

@@ -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);

View File

@@ -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 (
<Animated.View style={[styles.animatedContainer, styles.roundBorder, {transform: [{translateY}]}]}>
<TouchableWithFeedback
type={'native'}
onPress={this.onMoreMessagesPress}
underlayColor={underlayColor}
style={styles.roundBorder}
>
<View style={[styles.container, styles.roundBorder]}>
<View style={styles.iconContainer}>
{loadingPosts &&
<ActivityIndicator
animating={true}
size='small'
color={theme.buttonColor}
/>
}
{!loadingPosts &&
<VectorIcon
name='md-arrow-up'
type='ion'
style={styles.icon}
/>
}
</View>
<View style={styles.textContainer}>
<Text style={styles.text}>{moreText}</Text>
</View>
<TouchableWithFeedback
type={'none'}
onPress={this.cancel}
>
<View style={styles.cancelContainer}>
<VectorIcon
name='md-close'
type='ion'
style={styles.icon}
/>
</View>
</TouchableWithFeedback>
</View>
</TouchableWithFeedback>
</Animated.View>
);
}
}
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',
},
};
});

View File

@@ -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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...props}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...props}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
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(
<MoreMessagesButton {...baseProps}/>,
);
const instance = wrapper.instance();
instance.pressed = true;
instance.onScrollEndIndex();
expect(instance.pressed).toBe(false);
});
});
describe('moreText', () => {
const wrapper = shallowWithIntl(
<MoreMessagesButton {...baseProps}/>,
);
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');
});
});
});

View File

@@ -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 (
<Post
<MemoizedPost
postId={postId}
highlight={highlightPostId === postId}
isLastPost={lastPostIndex === index}
@@ -408,7 +389,6 @@ export default class PostList extends PureComponent {
resetPostList = () => {
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 (
<FlatList
contentContainerStyle={styles.postListContent}
data={postIds}
extraData={this.makeExtraData(channelId, highlightPostId, extraData, loadMorePostsVisible)}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
inverted={true}
key={`recyclerFor-${channelId}-${hasPostsKey}`}
keyboardDismissMode={'interactive'}
keyboardShouldPersistTaps={'handled'}
keyExtractor={this.keyExtractor}
ListFooterComponent={this.props.renderFooter}
listKey={`recyclerFor-${channelId}`}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={Platform.select({android: 5})}
nativeID={scrollViewNativeID}
onContentSizeChange={this.handleContentSizeChange}
onLayout={this.handleLayout}
onScroll={this.handleScroll}
onScrollBeginDrag={this.handleScrollBeginDrag}
onScrollToIndexFailed={this.handleScrollToIndexFailed}
ref={this.flatListRef}
refreshControl={refreshControl}
removeClippedSubviews={false}
renderItem={this.renderItem}
scrollEventThrottle={60}
style={styles.flex}
windowSize={Platform.select({android: 11, ios: 50})}
/>
<>
<FlatList
contentContainerStyle={styles.postListContent}
data={postIds}
extraData={this.makeExtraData(channelId, highlightPostId, extraData, loadMorePostsVisible)}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
inverted={true}
key={`recyclerFor-${channelId}-${hasPostsKey}`}
keyboardDismissMode={'interactive'}
keyboardShouldPersistTaps={'handled'}
keyExtractor={this.keyExtractor}
ListFooterComponent={this.props.renderFooter}
listKey={`recyclerFor-${channelId}`}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={Platform.select({android: 5})}
nativeID={scrollViewNativeID}
onContentSizeChange={this.handleContentSizeChange}
onLayout={this.handleLayout}
onScroll={this.handleScroll}
onScrollToIndexFailed={this.handleScrollToIndexFailed}
ref={this.flatListRef}
refreshControl={refreshControl}
removeClippedSubviews={false}
renderItem={this.renderItem}
scrollEventThrottle={60}
style={styles.flex}
windowSize={Posts.POST_CHUNK_SIZE / 2}
viewabilityConfig={{
itemVisiblePercentThreshold: 1,
minimumViewTime: 100,
}}
onViewableItemsChanged={showMoreMessagesButton ? this.onViewableItemsChanged : null}
/>
{showMoreMessagesButton &&
<MoreMessagesButton
theme={theme}
postIds={postIds}
channelId={channelId}
deepLinkURL={deepLinkURL}
newMessageLineIndex={initialIndex}
scrollToIndex={this.scrollToIndex}
registerViewableItemsListener={this.registerViewableItemsListener}
registerScrollEndIndexListener={this.registerScrollEndIndexListener}
/>
}
</>
);
}
}
function PostComponent({postId, highlightPostId, lastPostIndex, index, ...postProps}) {
return (
<Post
postId={postId}
highlight={highlightPostId === postId}
isLastPost={lastPostIndex === index}
{...postProps}
/>
);
}
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,

View File

@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RetryBarIndicator should match snapshot 1`] = `
<ForwardRef(AnimatedComponentWrapper)
style={
Array [
Object {
"alignItems": "center",
"backgroundColor": "#fb8000",
"flexDirection": "row",
"overflow": "hidden",
"paddingHorizontal": 10,
"position": "absolute",
"top": 0,
"width": "100%",
},
Object {
"height": 0,
},
]
}
>
<FormattedText
defaultMessage="Refreshing messages failed. Pull up to try again."
id="mobile.retry_message"
style={
Object {
"color": "white",
"flex": 1,
"fontSize": 12,
}
}
/>
</ForwardRef(AnimatedComponentWrapper)>
`;

View File

@@ -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() {

View File

@@ -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(
<RetryBarIndicator/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
describe('toggleRetryMessage', () => {
Animated.timing = jest.fn(() => ({
start: jest.fn((cb) => {
cb();
}),
}));
EventEmitter.emit = jest.fn();
const wrapper = shallow(
<RetryBarIndicator/>,
);
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);
});
});
});

View File

@@ -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',

View File

@@ -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,

View File

@@ -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};
};
}

View File

@@ -70,6 +70,7 @@ export type Post = {
failed?: boolean;
user_activity_posts?: Array<Post>;
state?: 'DELETED';
ownPost?: boolean;
};
export type PostWithFormatData = Post & {

View File

@@ -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);
});
});

View File

@@ -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<types.posts.Pos
return extractUserActivityData(userActivities);
}
export function messageCount(postIds: string[]) {
const messagePostIds = postIds.filter((postId) => {
return !isStartOfNewMessages(postId) && !isDateLine(postId) && !isCombinedUserActivityPost(postId);
});
return messagePostIds.length;
}

View File

@@ -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,
});

View File

@@ -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: {},
},
{},
);

View File

@@ -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);

View File

@@ -27,6 +27,7 @@ describe('ChannelBase', () => {
recordLoadTime: jest.fn(),
selectDefaultTeam: jest.fn(),
selectInitialChannel: jest.fn(),
resetUnreadMessageCount: jest.fn(),
},
componentId: channelBaseComponentId,
theme: Preferences.THEMES.default,

View File

@@ -190,6 +190,7 @@ export default class ChannelPostList extends PureComponent {
refreshing={refreshing}
scrollViewNativeID={channelId}
loadMorePostsVisible={this.props.loadMorePostsVisible}
showMoreMessagesButton={true}
/>
);
}

View File

@@ -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),
};
}

View File

@@ -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;
}

View File

@@ -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}.",