[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:
parent
4e7e37499d
commit
724d72d98a
8 changed files with 114 additions and 29 deletions
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
app/hooks/fetching_thread.ts
Normal file
22
app/hooks/fetching_thread.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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'
|
||||
/>
|
||||
|
|
|
|||
18
app/store/fetching_thread_store.ts
Normal file
18
app/store/fetching_thread_store.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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}}",
|
||||
|
|
|
|||
Loading…
Reference in a new issue