MM-23230 Batch post actions and fine tune postlint (#4042)

* PostList optimizations on FlatList

* Stop scroll to index if there is an interaction

* Fix unit tests

* MM-23176 Fix crash due to scrollToIndex out of range

* Batch mark channel as read action

* Fine tune post list

* Batch actions for getting posts

* Update app/utils/push_notifications.js

Co-Authored-By: Miguel Alatzar <migbot@users.noreply.github.com>

* Update app/actions/views/channel.js

Co-Authored-By: Miguel Alatzar <migbot@users.noreply.github.com>

* Pass state as arg to markAsViewedAndReadBatch

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
This commit is contained in:
Elias Nahum 2020-03-18 16:59:45 -03:00 committed by GitHub
parent e8150fa7cc
commit ec4dfb65b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 558 additions and 132 deletions

View file

@ -13,12 +13,6 @@ import {
markChannelAsViewed,
leaveChannel as serviceLeaveChannel,
} from 'mattermost-redux/actions/channels';
import {
getPosts,
getPostsBefore,
getPostsSince,
getPostThread,
} from 'mattermost-redux/actions/posts';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
@ -56,6 +50,7 @@ import telemetry from 'app/telemetry';
import {isDirectChannelVisible, isGroupChannelVisible, isDirectMessageVisible, isGroupMessageVisible, isDirectChannelAutoClosed} from 'app/utils/channels';
import {buildPreference} from 'app/utils/preferences';
import {getPosts, getPostsBefore, getPostsSince, getPostThread} from './post';
import {forceLogoutIfNecessary} from './user';
const MAX_RETRIES = 3;
@ -359,7 +354,8 @@ export function handleSelectChannel(channelId) {
dispatch(loadPostsIfNecessaryWithRetry(channelId));
if (channel && currentChannelId !== channelId) {
dispatch({
const actions = markAsViewedAndReadBatch(state, channelId, currentChannelId);
actions.push({
type: ChannelTypes.SELECT_CHANNEL,
data: channelId,
extra: {
@ -368,11 +364,10 @@ export function handleSelectChannel(channelId) {
teamId: channel.team_id || currentTeamId,
},
});
dispatch(markChannelViewedAndRead(channelId, currentChannelId));
dispatch(batchActions(actions));
}
console.log('channel switch to', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line
console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
};
}
@ -435,6 +430,81 @@ export function markChannelViewedAndRead(channelId, previousChannelId, markOnSer
};
}
function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', markOnServer = true) {
const actions = [];
const {channels, myMembers} = state.entities.channels;
const channel = channels[channelId];
const member = myMembers[channelId];
const prevMember = myMembers[prevChannelId];
const prevChanManuallyUnread = isManuallyUnread(state, prevChannelId);
const prevChannel = (!prevChanManuallyUnread && prevChannelId) ? channels[prevChannelId] : null; // May be null since prevChannelId is optional
if (markOnServer) {
Client4.viewMyChannel(channelId, prevChanManuallyUnread ? '' : prevChannelId);
}
if (member) {
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
data: {...member, last_viewed_at: Date.now()},
});
if (isManuallyUnread(state, channelId)) {
actions.push({
type: ChannelTypes.REMOVE_MANUALLY_UNREAD,
data: {channelId},
});
}
if (channel) {
actions.push({
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
data: {
teamId: channel.team_id,
channelId,
amount: channel.total_msg_count - member.msg_count,
},
}, {
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
data: {
teamId: channel.team_id,
channelId,
amount: member.mention_count,
},
});
}
}
if (prevMember) {
if (!prevChanManuallyUnread) {
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
data: {...prevMember, last_viewed_at: Date.now()},
});
}
if (prevChannel) {
actions.push({
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
data: {
teamId: prevChannel.team_id,
channelId: prevChannelId,
amount: prevChannel.total_msg_count - prevMember.msg_count,
},
}, {
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
data: {
teamId: prevChannel.team_id,
channelId: prevChannelId,
amount: prevMember.mention_count,
},
});
}
}
return actions;
}
export function markChannelViewedAndReadOnReconnect(channelId) {
return (dispatch, getState) => {
if (isManuallyUnread(getState(), channelId)) {

View file

@ -66,7 +66,7 @@ describe('Actions.Views.Channel', () => {
type: MOCK_SELECT_CHANNEL_TYPE,
data: 'selected-channel-id',
});
const postActions = require('mattermost-redux/actions/posts');
const postActions = require('./post');
postActions.getPostsSince = jest.fn(() => {
return {
type: MOCK_RECEIVED_POSTS_SINCE,
@ -116,6 +116,7 @@ describe('Actions.Views.Channel', () => {
},
channels: {
currentChannelId,
manuallyUnread: {},
channels: {
'channel-id': {id: 'channel-id', display_name: 'Test Channel'},
'channel-id-2': {id: 'channel-id-2', display_name: 'Test Channel'},
@ -296,7 +297,8 @@ describe('Actions.Views.Channel', () => {
await store.dispatch(handleSelectChannel(channelId));
const storeActions = store.getActions();
const selectChannelWithMember = storeActions.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
const storeBatchActions = storeActions.find(({type}) => type === 'BATCHING_REDUCER.BATCH');
const selectChannelWithMember = storeBatchActions?.payload.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED);
const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ);

View file

@ -1,7 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {addReaction as serviceAddReaction} from 'mattermost-redux/actions/posts';
import {batchActions} from 'redux-batched-actions';
import {EmojiTypes} from 'mattermost-redux/action_types';
import {addReaction as serviceAddReaction, getNeededCustomEmojis} from 'mattermost-redux/actions/posts';
import {Client4} from 'mattermost-redux/client';
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
import {ViewTypes} from 'app/constants';
@ -42,3 +46,55 @@ export function incrementEmojiPickerPage() {
return {data: true};
};
}
export function getEmojisInPosts(posts) {
return async (dispatch, getState) => {
const state = getState();
// Do not wait for this as they need to be loaded one by one
const emojisToLoad = getNeededCustomEmojis(state, posts);
if (emojisToLoad?.size > 0) {
const promises = emojisToLoad.map((name) => getCustomEmojiByName(name));
const result = await Promise.all(promises);
const actions = [];
const data = [];
result.forEach((emoji, index) => {
const name = emojisToLoad[index];
if (emoji) {
switch (emoji) {
case 404:
actions.push({type: EmojiTypes.CUSTOM_EMOJI_DOES_NOT_EXIST, data: name});
break;
default:
data.push(emoji);
}
}
});
if (data.length) {
actions.push({type: EmojiTypes.RECEIVED_CUSTOM_EMOJIS, data});
}
if (actions.length) {
dispatch(batchActions(actions));
}
}
};
}
async function getCustomEmojiByName(name) {
try {
const data = await Client4.getCustomEmojiByName(name);
return data;
} catch (error) {
if (error.status_code === 404) {
return 404;
}
}
return null;
}

View file

@ -1,13 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import {UserTypes} from 'mattermost-redux/action_types';
import {
doPostAction,
getNeededAtMentionedUsernames,
receivedNewPost,
receivedPosts,
receivedPostsBefore,
receivedPostsInChannel,
receivedPostsSince,
receivedPostsInThread,
} from 'mattermost-redux/actions/posts';
import {Client4} from 'mattermost-redux/client';
import {Posts} from 'mattermost-redux/constants';
import {doPostAction, receivedNewPost} from 'mattermost-redux/actions/posts';
import {removeUserFromList} from 'mattermost-redux/utils/user_utils';
import {ViewTypes} from 'app/constants';
import {generateId} from 'app/utils/file';
import {getEmojisInPosts} from './emoji';
export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
return async (dispatch) => {
const timestamp = Date.now();
@ -58,3 +73,261 @@ export function selectAttachmentMenuAction(postId, actionId, text, value) {
dispatch(doPostAction(postId, actionId, value));
};
}
export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
return async (dispatch) => {
try {
const data = await Client4.getPosts(channelId, page, perPage);
const posts = Object.values(data.posts);
if (posts?.length) {
const actions = [
receivedPosts(data),
receivedPostsInChannel(data, channelId, page === 0, data.prev_post_id === ''),
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
}
dispatch(batchActions(actions));
}
return {data};
} catch (error) {
return {error};
}
};
}
export function getPostsSince(channelId, since) {
return async (dispatch) => {
try {
const data = await Client4.getPostsSince(channelId, since);
const posts = Object.values(data.posts);
if (posts?.length) {
const actions = [
receivedPosts(data),
receivedPostsSince(data, channelId),
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
}
dispatch(batchActions(actions));
}
return {data};
} catch (error) {
return {error};
}
};
}
export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
return async (dispatch) => {
try {
const data = await Client4.getPostsBefore(channelId, postId, page, perPage);
const posts = Object.values(data.posts);
if (posts?.length) {
const actions = [
receivedPosts(data),
receivedPostsBefore(data, channelId, postId, data.prev_post_id === ''),
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
}
dispatch(batchActions(actions));
}
return {data};
} catch (error) {
return {error};
}
};
}
export function getPostThread(rootId) {
return async (dispatch) => {
try {
const data = await Client4.getPostThread(rootId);
const posts = Object.values(data.posts);
if (posts.length) {
const actions = [
receivedPosts(data),
receivedPostsInThread(data, rootId),
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
}
dispatch(batchActions(actions));
}
return {data};
} catch (error) {
return {error};
}
};
}
export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZE / 2) {
return async (dispatch) => {
try {
const [before, thread, after] = await Promise.all([
Client4.getPostsBefore(channelId, postId, 0, perPage),
Client4.getPostThread(postId),
Client4.getPostsAfter(channelId, postId, 0, perPage),
]);
const data = {
posts: {
...after.posts,
...thread.posts,
...before.posts,
},
order: [ // Remember that the order is newest posts first
...after.order,
postId,
...before.order,
],
next_post_id: after.next_post_id,
prev_post_id: before.prev_post_id,
};
const posts = Object.values(data.posts);
if (posts?.length) {
const actions = [
receivedPosts(data),
receivedPostsInChannel(data, channelId, after.next_post_id === '', before.prev_post_id === ''),
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
}
dispatch(batchActions(actions));
}
return {data};
} catch (error) {
return {error};
}
};
}
function getPostsAdditionalDataBatch(posts = []) {
return async (dispatch, getState) => {
const actions = [];
if (!posts.length) {
return actions;
}
// Custom Emojis used in the posts
// Do not wait for this as they need to be loaded one by one
dispatch(getEmojisInPosts(posts));
try {
const state = getState();
const promises = [];
const promiseTrace = [];
const extra = dispatch(profilesStatusesAndToLoadFromPosts(posts));
if (extra?.userIds.length) {
promises.push(Client4.getProfilesByIds(extra.userIds));
promiseTrace.push('ids');
}
if (extra?.usernames.length) {
promises.push(Client4.getProfilesByUsernames(extra.usernames));
promiseTrace.push('usernames');
}
if (extra?.statuses.length) {
promises.push(Client4.getStatusesByIds(extra.statuses));
promiseTrace.push('statuses');
}
if (promises.length) {
const result = await Promise.all(promises);
result.forEach((p, index) => {
if (p.length) {
const type = promiseTrace[index];
switch (type) {
case 'statuses':
actions.push({
type: UserTypes.RECEIVED_STATUSES,
data: p,
});
break;
default: {
const {currentUserId} = state.entities.users;
removeUserFromList(currentUserId, p);
actions.push({
type: UserTypes.RECEIVED_PROFILES_LIST,
data: p,
});
break;
}
}
}
});
}
} catch (error) {
// do nothing
}
return actions;
};
}
function profilesStatusesAndToLoadFromPosts(posts = []) {
return (dispatch, getState) => {
const state = getState();
const {currentUserId, profiles, statuses} = state.entities.users;
// Profiles of users mentioned in the posts
const usernamesToLoad = getNeededAtMentionedUsernames(state, posts);
// Statuses and profiles of the users who made the posts
const userIdsToLoad = new Set();
const statusesToLoad = new Set();
posts.forEach((post) => {
const userId = post.user_id;
if (!statuses[userId]) {
statusesToLoad.add(userId);
}
if (userId === currentUserId) {
return;
}
if (!profiles[userId]) {
userIdsToLoad.add(userId);
}
});
return {
usernames: Array.from(usernamesToLoad),
userIds: Array.from(userIdsToLoad),
statuses: Array.from(statusesToLoad),
};
};
}

View file

@ -14,7 +14,7 @@ exports[`PostList setting channel deep link 1`] = `
"post-id-2",
]
}
disableVirtualization={false}
disableVirtualization={true}
extraData={
Array [
"channel-id",
@ -24,18 +24,19 @@ exports[`PostList setting channel deep link 1`] = `
]
}
horizontal={false}
initialNumToRender={7}
initialNumToRender={10}
inverted={true}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
listKey="recyclerFor-channel-id"
maintainVisibleContentPosition={
Object {
"autoscrollToTopThreshold": 60,
"minIndexForVisible": 0,
}
}
maxToRenderPerBatch={10}
maxToRenderPerBatch={5}
numColumns={1}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
@ -55,7 +56,7 @@ exports[`PostList setting channel deep link 1`] = `
tintColor="#3d3c40"
/>
}
removeClippedSubviews={true}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={60}
style={
@ -64,7 +65,7 @@ exports[`PostList setting channel deep link 1`] = `
}
}
updateCellsBatchingPeriod={50}
windowSize={50}
windowSize={11}
/>
`;
@ -82,7 +83,7 @@ exports[`PostList setting permalink deep link 1`] = `
"post-id-2",
]
}
disableVirtualization={false}
disableVirtualization={true}
extraData={
Array [
"channel-id",
@ -92,18 +93,19 @@ exports[`PostList setting permalink deep link 1`] = `
]
}
horizontal={false}
initialNumToRender={7}
initialNumToRender={10}
inverted={true}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
listKey="recyclerFor-channel-id"
maintainVisibleContentPosition={
Object {
"autoscrollToTopThreshold": 60,
"minIndexForVisible": 0,
}
}
maxToRenderPerBatch={10}
maxToRenderPerBatch={5}
numColumns={1}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
@ -123,7 +125,7 @@ exports[`PostList setting permalink deep link 1`] = `
tintColor="#3d3c40"
/>
}
removeClippedSubviews={true}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={60}
style={
@ -132,7 +134,7 @@ exports[`PostList setting permalink deep link 1`] = `
}
}
updateCellsBatchingPeriod={50}
windowSize={50}
windowSize={11}
/>
`;
@ -150,7 +152,7 @@ exports[`PostList should match snapshot 1`] = `
"post-id-2",
]
}
disableVirtualization={false}
disableVirtualization={true}
extraData={
Array [
"channel-id",
@ -160,18 +162,19 @@ exports[`PostList should match snapshot 1`] = `
]
}
horizontal={false}
initialNumToRender={7}
initialNumToRender={10}
inverted={true}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
listKey="recyclerFor-channel-id"
maintainVisibleContentPosition={
Object {
"autoscrollToTopThreshold": 60,
"minIndexForVisible": 0,
}
}
maxToRenderPerBatch={10}
maxToRenderPerBatch={5}
numColumns={1}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
@ -191,7 +194,7 @@ exports[`PostList should match snapshot 1`] = `
tintColor="#3d3c40"
/>
}
removeClippedSubviews={true}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={60}
style={
@ -200,6 +203,6 @@ exports[`PostList should match snapshot 1`] = `
}
}
updateCellsBatchingPeriod={50}
windowSize={50}
windowSize={11}
/>
`;

View file

@ -3,7 +3,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Alert, FlatList, RefreshControl, StyleSheet} from 'react-native';
import {Alert, FlatList, InteractionManager, RefreshControl, StyleSheet} from 'react-native';
import {intlShape} from 'react-intl';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
@ -24,8 +24,7 @@ import {t} from 'app/utils/i18n';
import DateHeader from './date_header';
import NewMessagesDivider from './new_messages_divider';
const INITIAL_BATCH_TO_RENDER = 7;
const LOADING_POSTS_HEIGHT = 53;
const INITIAL_BATCH_TO_RENDER = 10;
const SCROLL_UP_MULTIPLIER = 3.5;
const SCROLL_POSITION_CONFIG = {
@ -90,20 +89,18 @@ export default class PostList extends PureComponent {
constructor(props) {
super(props);
this.hasDoneInitialScroll = false;
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();
this.state = {
postListHeight: 0,
};
}
componentDidMount() {
const {actions, deepLinkURL} = this.props;
const {actions, deepLinkURL, highlightPostId, initialIndex} = this.props;
EventEmitter.on('scroll-to-bottom', this.handleSetScrollToBottom);
@ -112,6 +109,11 @@ export default class PostList extends PureComponent {
this.handleDeepLink(deepLinkURL);
actions.setDeepLinkURL('');
}
// Scroll to highlighted post for permalinks
if (!this.hasDoneInitialScroll && initialIndex > 0 && !this.cancelScrollToIndex && highlightPostId) {
this.scrollToInitialIndexIfNeeded(initialIndex);
}
}
componentDidUpdate(prevProps) {
@ -132,16 +134,16 @@ export default class PostList extends PureComponent {
this.shouldScrollToBottom = false;
}
if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && this.state.contentHeight > LOADING_POSTS_HEIGHT) {
if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && !this.cancelScrollToIndex) {
this.scrollToInitialIndexIfNeeded(this.props.initialIndex);
}
if (
this.props.channelId === prevProps.channelId &&
this.props.postIds.length &&
this.state.contentHeight &&
this.state.contentHeight < this.state.postListHeight &&
!this.props.extraData
this.contentHeight &&
this.contentHeight < this.postListHeight &&
this.props.extraData
) {
this.loadToFillContent();
}
@ -176,14 +178,12 @@ export default class PostList extends PureComponent {
this.showingPermalink = false;
};
handleContentSizeChange = (contentWidth, contentHeight) => {
if (this.state.contentHeight !== contentHeight) {
this.setState({contentHeight}, () => {
if (this.state.postListHeight && contentHeight < this.state.postListHeight && !this.props.extraData && contentHeight > LOADING_POSTS_HEIGHT) {
// We still have less than 1 screen of posts loaded with more to get, so load more
this.props.onLoadMoreUp();
}
});
handleContentSizeChange = (contentWidth, contentHeight, forceLoad) => {
this.contentHeight = contentHeight;
const loadMore = forceLoad || !this.props.extraData;
if (this.postListHeight && contentHeight < this.postListHeight && loadMore) {
// We still have less than 1 screen of posts loaded with more to get, so load more
this.props.onLoadMoreUp();
}
};
@ -194,7 +194,7 @@ export default class PostList extends PureComponent {
if (match) {
if (match.type === DeepLinkTypes.CHANNEL) {
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.errorBadChannel);
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.permalinkBadChannel);
} else if (match.type === DeepLinkTypes.PERMALINK) {
this.handlePermalinkPress(match.postId, match.teamName);
}
@ -215,31 +215,11 @@ export default class PostList extends PureComponent {
handleLayout = (event) => {
const {height} = event.nativeEvent.layout;
if (this.state.postListHeight !== height) {
this.setState({postListHeight: height});
if (this.postListHeight !== height) {
this.postListHeight = height;
}
};
errorBadTeam = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_team.error'),
defaultMessage: 'This link belongs to a deleted team or to a team to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
errorBadChannel = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_channel.error'),
defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
handlePermalinkPress = (postId, teamName) => {
telemetry.start(['post_list:permalink']);
const {actions, onPermalinkPress} = this.props;
@ -247,7 +227,7 @@ export default class PostList extends PureComponent {
if (onPermalinkPress) {
onPermalinkPress(postId, true);
} else {
actions.loadChannelsByTeamName(teamName, this.errorBadTeam);
actions.loadChannelsByTeamName(teamName, this.permalinkBadTeam);
this.showPermalinkView(postId);
}
};
@ -272,14 +252,12 @@ export default class PostList extends PureComponent {
const pageOffsetY = event.nativeEvent.contentOffset.y;
if (pageOffsetY > 0) {
const contentHeight = event.nativeEvent.contentSize.height;
const direction = (this.contentOffsetY < pageOffsetY) ?
ListTypes.VISIBILITY_SCROLL_UP :
ListTypes.VISIBILITY_SCROLL_DOWN;
const direction = (this.contentOffsetY < pageOffsetY) ? ListTypes.VISIBILITY_SCROLL_UP : ListTypes.VISIBILITY_SCROLL_DOWN;
this.contentOffsetY = pageOffsetY;
if (
direction === ListTypes.VISIBILITY_SCROLL_UP &&
(contentHeight - pageOffsetY) < (this.state.postListHeight * SCROLL_UP_MULTIPLIER)
(contentHeight - pageOffsetY) < (this.postListHeight * SCROLL_UP_MULTIPLIER)
) {
this.props.onLoadMoreUp();
}
@ -290,15 +268,25 @@ export default class PostList extends PureComponent {
this.cancelScrollToIndex = true;
}
handleScrollToIndexFailed = () => {
handleScrollToIndexFailed = (info) => {
this.animationFrameIndexFailed = requestAnimationFrame(() => {
if (this.props.initialIndex > 0 && this.state.contentHeight > 0) {
if (this.props.initialIndex > 0 && this.contentHeight > 0) {
this.hasDoneInitialScroll = false;
this.scrollToInitialIndexIfNeeded(this.props.initialIndex);
if (info.highestMeasuredFrameIndex) {
this.scrollToInitialIndexIfNeeded(info.highestMeasuredFrameIndex);
} else {
this.scrollAfterInteraction = InteractionManager.runAfterInteractions(() => {
this.scrollToInitialIndexIfNeeded(info.index);
});
}
}
});
};
handleScrollBeginDrag = () => {
this.cancelScrollToIndex = true;
};
handleSetScrollToBottom = () => {
this.shouldScrollToBottom = true;
}
@ -310,10 +298,30 @@ export default class PostList extends PureComponent {
loadToFillContent = () => {
this.fillContentTimer = setTimeout(() => {
this.handleContentSizeChange(0, this.state.contentHeight);
this.handleContentSizeChange(0, this.contentHeight, true);
});
};
permalinkBadTeam = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_team.error'),
defaultMessage: 'This link belongs to a deleted team or to a team to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
permalinkBadChannel = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_channel.error'),
defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
renderItem = ({item, index}) => {
const {
highlightPinnedOrFlagged,
@ -397,19 +405,15 @@ export default class PostList extends PureComponent {
);
};
scrollToBottom = () => {
this.scrollToBottomTimer = setTimeout(() => {
if (this.flatListRef.current) {
this.flatListRef.current.scrollToOffset({offset: 0, animated: true});
}
}, 250);
};
resetPostList = () => {
this.contentOffsetY = 0;
this.hasDoneInitialScroll = false;
this.cancelScrollToIndex = false;
if (this.scrollAfterInteraction) {
this.scrollAfterInteraction.cancel();
}
if (this.animationFrameIndexFailed) {
cancelAnimationFrame(this.animationFrameIndexFailed);
}
@ -430,11 +434,19 @@ export default class PostList extends PureComponent {
clearTimeout(this.scrollToInitialTimer);
}
if (this.state.contentHeight !== 0) {
this.setState({contentHeight: 0});
if (this.contentHeight !== 0) {
this.contentHeight = 0;
}
}
scrollToBottom = () => {
this.scrollToBottomTimer = setTimeout(() => {
if (this.flatListRef.current) {
this.flatListRef.current.scrollToOffset({offset: 0, animated: true});
}
}, 250);
};
scrollToIndex = (index) => {
this.animationFrameInitialIndex = requestAnimationFrame(() => {
if (this.flatListRef.current && index > 0 && index <= this.getItemCount()) {
@ -443,15 +455,18 @@ export default class PostList extends PureComponent {
});
};
scrollToInitialIndexIfNeeded = (index, count = 0) => {
scrollToInitialIndexIfNeeded = (index) => {
if (!this.hasDoneInitialScroll) {
if (index > 0 && index <= this.getItemCount()) {
this.hasDoneInitialScroll = true;
this.scrollToIndex(index);
} else if (count < 3) {
this.scrollToInitialTimer = setTimeout(() => {
this.scrollToInitialIndexIfNeeded(index, count + 1);
}, 300);
if (index !== this.props.initialIndex) {
this.hasDoneInitialScroll = false;
this.scrollToInitialTimer = setTimeout(() => {
this.scrollToInitialIndexIfNeeded(this.props.initialIndex);
});
}
}
}
};
@ -499,40 +514,44 @@ export default class PostList extends PureComponent {
tintColor={theme.centerChannelColor}
/>);
const hasPostsKey = postIds.length ? 'true' : 'false';
return (
<FlatList
key={`recyclerFor-${channelId}-${hasPostsKey}`}
ref={this.flatListRef}
style={{flex: 1}}
contentContainerStyle={styles.postListContent}
data={postIds}
disableVirtualization={true}
extraData={this.makeExtraData(channelId, highlightPostId, extraData, loadMorePostsVisible)}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
inverted={true}
key={`recyclerFor-${channelId}`}
keyboardDismissMode={'interactive'}
keyboardShouldPersistTaps={'handled'}
keyExtractor={this.keyExtractor}
ListFooterComponent={this.props.renderFooter}
listKey={`recyclerFor-${channelId}`}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={5}
nativeID={scrollViewNativeID}
onContentSizeChange={this.handleContentSizeChange}
onLayout={this.handleLayout}
onScroll={this.handleScroll}
onScrollBeginDrag={this.handleScrollBeginDrag}
onScrollToIndexFailed={this.handleScrollToIndexFailed}
removeClippedSubviews={true}
ref={this.flatListRef}
refreshControl={refreshControl}
removeClippedSubviews={false}
renderItem={this.renderItem}
scrollEventThrottle={60}
refreshControl={refreshControl}
nativeID={scrollViewNativeID}
windowSize={50}
style={styles.flex}
windowSize={11}
/>
);
}
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
postListContent: {
paddingTop: 5,
},

View file

@ -113,12 +113,8 @@ describe('PostList', () => {
});
expect(instance.loadToFillContent).toHaveBeenCalledTimes(0);
wrapper.setState({
postListHeight: 500,
contentHeight: 200,
});
expect(instance.loadToFillContent).toHaveBeenCalledTimes(1);
instance.postListHeight = 500;
instance.contentHeight = 200;
wrapper.setProps({
extraData: true,
});

View file

@ -5,6 +5,7 @@ import DeepLinkTypes from './deep_linking';
import DeviceTypes from './device';
import ListTypes from './list';
import NavigationTypes from './navigation';
import Types from './types';
import ViewTypes, {UpgradeTypes} from './view';
export {
@ -13,5 +14,6 @@ export {
ListTypes,
NavigationTypes,
UpgradeTypes,
Types,
ViewTypes,
};

7
app/constants/types.js Normal file
View file

@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export default {
EMPTY_ARRAY: [],
EMTPY_OBJECT: {},
};

View file

@ -177,7 +177,7 @@ export default class ChannelPostList extends PureComponent {
<PostList
postIds={postIds}
lastPostIndex={Platform.OS === 'android' ? getLastPostIndex(postIds) : -1}
extraData={postIds.length === 0}
extraData={postIds.length !== 0}
onLoadMoreUp={this.loadMorePostsTop}
onPostPress={this.goToThread}
onRefresh={actions.setChannelRefreshing}

View file

@ -17,6 +17,7 @@ import {
refreshChannelWithRetry,
} from 'app/actions/views/channel';
import {recordLoadTime} from 'app/actions/views/root';
import {Types} from 'app/constants';
import {isLandscape} from 'app/selectors/device';
import ChannelPostList from './channel_post_list';
@ -30,7 +31,7 @@ function mapStateToProps(state) {
channelRefreshingFailed,
currentUserId: getCurrentUserId(state),
deviceHeight: state.device.dimension.deviceHeight,
postIds: getPostIdsInCurrentChannel(state) || [],
postIds: getPostIdsInCurrentChannel(state) || Types.EMPTY_ARRAY,
lastViewedAt: state.views.channel.lastChannelViewTime[channelId],
loadMorePostsVisible: state.views.channel.loadMorePostsVisible,
refreshing: state.views.channel.refreshing,

View file

@ -5,23 +5,20 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {getChannel as getChannelAction, joinChannel} from 'mattermost-redux/actions/channels';
import {
getPostsAround,
getPostThread,
selectPost,
} from 'mattermost-redux/actions/posts';
import {selectPost} from 'mattermost-redux/actions/posts';
import {makeGetChannel, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/channels';
import {makeGetPostIdsAroundPost, getPost} from 'mattermost-redux/selectors/entities/posts';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {isLandscape} from 'app/selectors/device';
import {
handleSelectChannel,
loadThreadIfNecessary,
} from 'app/actions/views/channel';
import {getPostsAround, getPostThread} from 'app/actions/views/post';
import {handleTeamChange} from 'app/actions/views/select_team';
import {isLandscape} from 'app/selectors/device';
import Permalink from './permalink';

View file

@ -5,17 +5,17 @@ import {Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {setDeviceToken} from 'mattermost-redux/actions/general';
import {getPosts} from 'mattermost-redux/actions/posts';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {markChannelViewedAndRead, retryGetPostsAction} from 'app/actions/views/channel';
import {dismissAllModals, popToRoot} from 'app/actions/navigation';
import {getPosts} from 'app/actions/views/post';
import {
createPostForNotificationReply,
loadFromPushNotification,
} from 'app/actions/views/root';
import {dismissAllModals, popToRoot} from 'app/actions/navigation';
import {NavigationTypes, ViewTypes} from 'app/constants';
import {getLocalizedMessage} from 'app/i18n';

18
package-lock.json generated
View file

@ -2938,9 +2938,9 @@
"integrity": "sha512-1dVNHT76Uu5N3eJNTYcvxee+jzX4Z9lfciqRRHCU27ihbUcYi+iSc2iml5Ke1LXe1SyJCLA0+14Jh4tXJgOppA=="
},
"@hapi/hoek": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.5.0.tgz",
"integrity": "sha512-7XYT10CZfPsH7j9F1Jmg1+d0ezOux2oM2GfArAzLwWe4mE2Dr3hVjsAL6+TFY49RRJlCdJDMw3nJsLFroTc8Kw=="
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.5.1.tgz",
"integrity": "sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow=="
},
"@hapi/joi": {
"version": "15.1.1",
@ -3659,9 +3659,9 @@
}
},
"acorn": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz",
"integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz",
"integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==",
"dev": true
},
"acorn-globals": {
@ -9549,9 +9549,9 @@
"integrity": "sha1-kYiJ6hP40KQufFVyUO7nE63JXDU="
},
"kind-of": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA=="
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="
},
"klaw": {
"version": "1.3.1",