[MM-47483] Activity Indicator while loading thread posts (#6865)

* Fix

* Addressing feedback

* Disabled pull to refresh when thread is being fetched

* Test fail fix

* Feedback changes
This commit is contained in:
Anurag Shivarathri 2022-12-17 01:15:22 +05:30 committed by GitHub
parent 4e7e37499d
commit 724d72d98a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 114 additions and 29 deletions

View file

@ -23,6 +23,7 @@ import {getPostById, getRecentPostsInChannel} from '@queries/servers/post';
import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {queryAllUsers} from '@queries/servers/user';
import {setFetchingThreadState} from '@store/fetching_thread_store';
import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers';
import {logError} from '@utils/log';
import {processPostsFetched} from '@utils/post';
@ -583,6 +584,8 @@ export async function fetchPostThread(serverUrl: string, postId: string, options
return {error};
}
setFetchingThreadState(postId, true);
try {
const isCRTEnabled = await getIsCRTEnabled(operator.database);
@ -620,9 +623,11 @@ export async function fetchPostThread(serverUrl: string, postId: string, options
}
await operator.batchRecords(models);
}
setFetchingThreadState(postId, false);
return {posts: extractRecordsForTable<PostModel>(posts, MM_TABLES.SERVER.POST)};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
setFetchingThreadState(postId, false);
return {error};
}
}

View file

@ -30,6 +30,7 @@ type Props = {
currentTimezone: string | null;
currentUserId: string;
currentUsername: string;
disablePullToRefresh?: boolean;
highlightedId?: PostModel['id'];
highlightPinnedOrSaved?: boolean;
isCRTEnabled?: boolean;
@ -45,6 +46,7 @@ type Props = {
showMoreMessages?: boolean;
showNewMessageLine?: boolean;
footer?: ReactElement;
header?: ReactElement;
testID: string;
currentCallBarVisible?: boolean;
joinCallBannerVisible?: boolean;
@ -84,7 +86,9 @@ const PostList = ({
currentTimezone,
currentUserId,
currentUsername,
disablePullToRefresh,
footer,
header,
highlightedId,
highlightPinnedOrSaved = true,
isCRTEnabled,
@ -365,7 +369,7 @@ const PostList = ({
return (
<>
<PostListRefreshControl
enabled={enableRefreshControl}
enabled={!disablePullToRefresh && enableRefreshControl}
refreshing={refreshing}
onRefresh={onRefresh}
style={styles.container}
@ -377,6 +381,7 @@ const PostList = ({
keyboardShouldPersistTaps='handled'
keyExtractor={keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER + 5}
ListHeaderComponent={header}
ListFooterComponent={footer}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={10}

View file

@ -14,6 +14,7 @@ describe('ThreadOverview', () => {
const props = {
isSaved: true,
repliesCount: 0,
rootId: '',
rootPost: {} as PostModel,
testID: 'thread-overview',
};
@ -26,6 +27,7 @@ describe('ThreadOverview', () => {
const props = {
isSaved: false,
repliesCount: 2,
rootId: '',
rootPost: {} as PostModel,
testID: 'thread-overview',
};

View file

@ -13,6 +13,7 @@ import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useFetchingThreadState} from '@hooks/fetching_thread';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -23,6 +24,7 @@ import type PostModel from '@typings/database/models/servers/post';
type Props = {
isSaved: boolean;
repliesCount: number;
rootId: string;
rootPost?: PostModel;
testID: string;
style?: StyleProp<ViewStyle>;
@ -56,13 +58,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const ThreadOverview = ({isSaved, repliesCount, rootPost, style, testID}: Props) => {
const ThreadOverview = ({isSaved, repliesCount, rootId, rootPost, style, testID}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const isFetchingThread = useFetchingThreadState(rootId);
const onHandleSavePress = useCallback(preventDoubleTap(() => {
if (rootPost?.id) {
@ -98,30 +101,44 @@ const ThreadOverview = ({isSaved, repliesCount, rootPost, style, testID}: Props)
const saveButtonTestId = isSaved ? `${testID}.unsave.button` : `${testID}.save.button`;
let repliesCountElement;
if (repliesCount > 0) {
repliesCountElement = (
<FormattedText
style={styles.repliesCount}
id='thread.repliesCount'
defaultMessage='{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}'
testID={`${testID}.replies_count`}
values={{repliesCount}}
/>
);
} else if (isFetchingThread) {
repliesCountElement = (
<FormattedText
style={styles.repliesCount}
id='thread.loadingReplies'
defaultMessage='Loading replies...'
testID={`${testID}.loading_replies`}
/>
);
} else {
repliesCountElement = (
<FormattedText
style={styles.repliesCount}
id='thread.noReplies'
defaultMessage='No replies yet'
testID={`${testID}.no_replies`}
/>
);
}
return (
<View
style={containerStyle}
testID={testID}
>
<View style={styles.repliesCountContainer}>
{
repliesCount > 0 ? (
<FormattedText
style={styles.repliesCount}
id='thread.repliesCount'
defaultMessage='{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}'
testID={`${testID}.replies_count`}
values={{repliesCount}}
/>
) : (
<FormattedText
style={styles.repliesCount}
id='thread.noReplies'
defaultMessage='No replies yet'
testID={`${testID}.no_replies`}
/>
)
}
{repliesCountElement}
</View>
<View style={styles.optionsContainer}>
<TouchableOpacity

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {subject} from '@store/fetching_thread_store';
export const useFetchingThreadState = (rootId: string) => {
const [isFetching, setIsFetching] = useState(false);
useEffect(() => {
const sub = subject.pipe(
switchMap((s) => of$(s[rootId] || false)),
distinctUntilChanged(),
).subscribe(setIsFetching);
return () => sub.unsubscribe();
}, []);
return isFetching;
};

View file

@ -2,16 +2,19 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {StyleSheet, View} from 'react-native';
import {ActivityIndicator, StyleSheet, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {fetchPostThread} from '@actions/remote/post';
import {markThreadAsRead} from '@actions/remote/thread';
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import PostList from '@components/post_list';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {useIsTablet} from '@hooks/device';
import {useFetchingThreadState} from '@hooks/fetching_thread';
import {isMinimumServerVersion} from '@utils/helpers';
import type PostModel from '@typings/database/models/servers/post';
@ -42,23 +45,28 @@ const ThreadPostList = ({
}: Props) => {
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const theme = useTheme();
const isFetchingThread = useFetchingThreadState(rootPost.id);
const canLoadPosts = useRef(true);
const fetchingPosts = useRef(false);
const canLoadMorePosts = useRef(true);
const onEndReached = useCallback(debounce(async () => {
if (isMinimumServerVersion(version || '', 6, 7) && !fetchingPosts.current && canLoadPosts.current && posts.length) {
fetchingPosts.current = true;
const options: FetchPaginatedThreadOptions = {};
if (isMinimumServerVersion(version || '', 6, 7) && !isFetchingThread && canLoadMorePosts.current && posts.length) {
const options: FetchPaginatedThreadOptions = {
perPage: PER_PAGE_DEFAULT,
};
const lastPost = posts[posts.length - 1];
if (lastPost) {
options.fromPost = lastPost.id;
options.fromCreateAt = lastPost.createAt;
}
const result = await fetchPostThread(serverUrl, rootPost.id, options);
fetchingPosts.current = false;
canLoadPosts.current = Boolean(result?.posts?.length);
// Root post is always fetched, so the result would include +1
canLoadMorePosts.current = (result?.posts?.length || 0) > PER_PAGE_DEFAULT;
} else {
canLoadMorePosts.current = false;
}
}, 500), [rootPost, posts, version]);
}, 500), [isFetchingThread, rootPost, posts, version]);
const threadPosts = useMemo(() => {
return [...posts, rootPost];
@ -82,10 +90,16 @@ const ThreadPostList = ({
const lastViewedAt = isCRTEnabled ? (thread?.viewedAt ?? 0) : channelLastViewedAt;
let header;
if (isFetchingThread && threadPosts.length === 1) {
header = <ActivityIndicator color={theme.centerChannelColor}/>;
}
const postList = (
<PostList
channelId={rootPost.channelId}
contentContainerStyle={styles.container}
disablePullToRefresh={isFetchingThread}
isCRTEnabled={isCRTEnabled}
lastViewedAt={lastViewedAt}
location={Screens.THREAD}
@ -95,6 +109,7 @@ const ThreadPostList = ({
rootId={rootPost.id}
shouldShowJoinLeaveMessages={false}
showMoreMessages={isCRTEnabled}
header={header}
footer={<View style={styles.footer}/>}
testID='thread.post_list'
/>

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BehaviorSubject} from 'rxjs';
type State = {[id: string]: boolean};
const defaultState: State = {};
export const subject: BehaviorSubject<State> = new BehaviorSubject(defaultState);
export const setFetchingThreadState = (rootId: string, isFetching: boolean) => {
const prevState = subject.value;
subject.next({
...prevState,
[rootId]: isFetching,
});
};

View file

@ -895,6 +895,7 @@
"terms_of_service.title": "Terms of Service",
"thread.header.thread": "Thread",
"thread.header.thread_in": "in {channelName}",
"thread.loadingReplies": "Loading replies...",
"thread.noReplies": "No replies yet",
"thread.options.title": "Thread Actions",
"thread.repliesCount": "{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}",