From 724d72d98a00fb94cbd6f1790a93131c243b3b64 Mon Sep 17 00:00:00 2001 From: Anurag Shivarathri Date: Sat, 17 Dec 2022 01:15:22 +0530 Subject: [PATCH] [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 --- app/actions/remote/post.ts | 5 ++ app/components/post_list/post_list.tsx | 7 ++- .../thread_overview/thread_overview.test.tsx | 2 + .../thread_overview/thread_overview.tsx | 55 ++++++++++++------- app/hooks/fetching_thread.ts | 22 ++++++++ .../thread_post_list/thread_post_list.tsx | 33 ++++++++--- app/store/fetching_thread_store.ts | 18 ++++++ assets/base/i18n/en.json | 1 + 8 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 app/hooks/fetching_thread.ts create mode 100644 app/store/fetching_thread_store.ts diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index ac971708f..9ead1cc40 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -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(posts, MM_TABLES.SERVER.POST)}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + setFetchingThreadState(postId, false); return {error}; } } diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index e62d3daab..003529fcb 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -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 ( <> { 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', }; diff --git a/app/components/post_list/thread_overview/thread_overview.tsx b/app/components/post_list/thread_overview/thread_overview.tsx index 2a80341e0..f9f532e9c 100644 --- a/app/components/post_list/thread_overview/thread_overview.tsx +++ b/app/components/post_list/thread_overview/thread_overview.tsx @@ -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; @@ -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 = ( + + ); + } else if (isFetchingThread) { + repliesCountElement = ( + + ); + } else { + repliesCountElement = ( + + ); + } + return ( - { - repliesCount > 0 ? ( - - ) : ( - - ) - } + {repliesCountElement} { + const [isFetching, setIsFetching] = useState(false); + useEffect(() => { + const sub = subject.pipe( + switchMap((s) => of$(s[rootId] || false)), + distinctUntilChanged(), + ).subscribe(setIsFetching); + + return () => sub.unsubscribe(); + }, []); + + return isFetching; +}; diff --git a/app/screens/thread/thread_post_list/thread_post_list.tsx b/app/screens/thread/thread_post_list/thread_post_list.tsx index 86cdda67e..e30b11adb 100644 --- a/app/screens/thread/thread_post_list/thread_post_list.tsx +++ b/app/screens/thread/thread_post_list/thread_post_list.tsx @@ -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 = ; + } + const postList = ( } testID='thread.post_list' /> diff --git a/app/store/fetching_thread_store.ts b/app/store/fetching_thread_store.ts new file mode 100644 index 000000000..712ea7aaf --- /dev/null +++ b/app/store/fetching_thread_store.ts @@ -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 = new BehaviorSubject(defaultState); + +export const setFetchingThreadState = (rootId: string, isFetching: boolean) => { + const prevState = subject.value; + subject.next({ + ...prevState, + [rootId]: isFetching, + }); +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 8bb68a8f1..de12f0e01 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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}}",