forked from Ivasoft/mattermost-mobile
[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:
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)>
|
||||
`;
|
||||
@@ -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,
|
||||
|
||||
74
app/components/network_indicator/network_indicator.test.js
Normal file
74
app/components/network_indicator/network_indicator.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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)>
|
||||
`;
|
||||
21
app/components/post_list/more_messages_button/index.js
Normal file
21
app/components/post_list/more_messages_button/index.js
Normal 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);
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)>
|
||||
`;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export type Post = {
|
||||
failed?: boolean;
|
||||
user_activity_posts?: Array<Post>;
|
||||
state?: 'DELETED';
|
||||
ownPost?: boolean;
|
||||
};
|
||||
|
||||
export type PostWithFormatData = Post & {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('ChannelBase', () => {
|
||||
recordLoadTime: jest.fn(),
|
||||
selectDefaultTeam: jest.fn(),
|
||||
selectInitialChannel: jest.fn(),
|
||||
resetUnreadMessageCount: jest.fn(),
|
||||
},
|
||||
componentId: channelBaseComponentId,
|
||||
theme: Preferences.THEMES.default,
|
||||
|
||||
@@ -190,6 +190,7 @@ export default class ChannelPostList extends PureComponent {
|
||||
refreshing={refreshing}
|
||||
scrollViewNativeID={channelId}
|
||||
loadMorePostsVisible={this.props.loadMorePostsVisible}
|
||||
showMoreMessagesButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
Reference in New Issue
Block a user