From ec4dfb65b2a11b122126476fd680f251646116ea Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 18 Mar 2020 16:59:45 -0300 Subject: [PATCH] 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 * Update app/actions/views/channel.js Co-Authored-By: Miguel Alatzar * Pass state as arg to markAsViewedAndReadBatch Co-authored-by: Miguel Alatzar --- app/actions/views/channel.js | 90 +++++- app/actions/views/channel.test.js | 6 +- app/actions/views/emoji.js | 58 +++- app/actions/views/post.js | 277 +++++++++++++++++- .../__snapshots__/post_list.test.js.snap | 33 ++- app/components/post_list/post_list.js | 173 ++++++----- app/components/post_list/post_list.test.js | 8 +- app/constants/index.js | 2 + app/constants/types.js | 7 + .../channel_post_list/channel_post_list.js | 2 +- .../channel/channel_post_list/index.js | 3 +- app/screens/permalink/index.js | 9 +- app/utils/push_notifications.js | 4 +- package-lock.json | 18 +- 14 files changed, 558 insertions(+), 132 deletions(-) create mode 100644 app/constants/types.js diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index 8393cc2ef..b04a1d68f 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -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)) { diff --git a/app/actions/views/channel.test.js b/app/actions/views/channel.test.js index fefeb4ab0..03615be55 100644 --- a/app/actions/views/channel.test.js +++ b/app/actions/views/channel.test.js @@ -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); diff --git a/app/actions/views/emoji.js b/app/actions/views/emoji.js index eb62d2904..87978f1f0 100644 --- a/app/actions/views/emoji.js +++ b/app/actions/views/emoji.js @@ -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; +} \ No newline at end of file diff --git a/app/actions/views/post.js b/app/actions/views/post.js index 2bfae5354..d6a14f57a 100644 --- a/app/actions/views/post.js +++ b/app/actions/views/post.js @@ -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), + }; + }; +} \ No newline at end of file diff --git a/app/components/post_list/__snapshots__/post_list.test.js.snap b/app/components/post_list/__snapshots__/post_list.test.js.snap index d3223c26a..6ff13c964 100644 --- a/app/components/post_list/__snapshots__/post_list.test.js.snap +++ b/app/components/post_list/__snapshots__/post_list.test.js.snap @@ -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} /> `; diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index 1dc5573c5..b6ddbfc6f 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -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 ( ); } } const styles = StyleSheet.create({ + flex: { + flex: 1, + }, postListContent: { paddingTop: 5, }, diff --git a/app/components/post_list/post_list.test.js b/app/components/post_list/post_list.test.js index eb33aee35..fb6d43e18 100644 --- a/app/components/post_list/post_list.test.js +++ b/app/components/post_list/post_list.test.js @@ -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, }); diff --git a/app/constants/index.js b/app/constants/index.js index 89fecaa62..c059bf3ce 100644 --- a/app/constants/index.js +++ b/app/constants/index.js @@ -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, }; diff --git a/app/constants/types.js b/app/constants/types.js new file mode 100644 index 000000000..c1b8df4c1 --- /dev/null +++ b/app/constants/types.js @@ -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: {}, +}; \ No newline at end of file diff --git a/app/screens/channel/channel_post_list/channel_post_list.js b/app/screens/channel/channel_post_list/channel_post_list.js index 41428f84d..3f47d87dc 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.js +++ b/app/screens/channel/channel_post_list/channel_post_list.js @@ -177,7 +177,7 @@ export default class ChannelPostList extends PureComponent {