From f580d4daf0d2172fa285a5a87a1bec5100a70f63 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 27 Nov 2023 17:41:30 -0800 Subject: [PATCH] Restore post-thread caching behaviors (react-query refactor) (#2010) * Rework resolve-did and resolve-uri queries to be smarter about cache reuse * Precache handle resolutions * Remove old unused code * Load placeholder threads from the post-feed and notifications-feed queries * Remove logs * Fix bad ref * Add loading spinners to the cache-loading thread view * Scroll replies into view when loading threads * Add caching within a thread * Fix: dont show bottom border when the child spinner is active --- src/state/queries/notifications/feed.ts | 39 ++++- src/state/queries/post-feed.ts | 40 +++++- src/state/queries/post-thread.ts | 146 ++++++++++++++++++- src/state/queries/resolve-uri.ts | 85 ++++++++--- src/view/com/post-thread/PostThread.tsx | 164 +++++++++++----------- src/view/com/profile/ProfileFollowers.tsx | 2 +- src/view/com/profile/ProfileFollows.tsx | 2 +- src/view/screens/Profile.tsx | 2 +- 8 files changed, 369 insertions(+), 111 deletions(-) diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index d78370e0..54bd8754 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -7,11 +7,18 @@ import { BskyAgent, } from '@atproto/api' import chunk from 'lodash.chunk' -import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' +import { + useInfiniteQuery, + InfiniteData, + QueryKey, + useQueryClient, + QueryClient, +} from '@tanstack/react-query' import {getAgent} from '../../session' import {useModerationOpts} from '../preferences' import {shouldFilterNotif} from './util' import {useMutedThreads} from '#/state/muted-threads' +import {precacheProfile as precacheResolvedUri} from '../resolve-uri' const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const PAGE_SIZE = 30 @@ -48,6 +55,7 @@ export interface FeedPage { } export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { + const queryClient = useQueryClient() const moderationOpts = useModerationOpts() const threadMutes = useMutedThreads() const enabled = opts?.enabled !== false @@ -80,6 +88,9 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { for (const notif of notifsGrouped) { if (notif.subjectUri) { notif.subject = subjects.get(notif.subjectUri) + if (notif.subject) { + precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution + } } } @@ -99,6 +110,32 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { }) } +/** + * This helper is used by the post-thread placeholder function to + * find a post in the query-data cache + */ +export function findPostInQueryData( + queryClient: QueryClient, + uri: string, +): AppBskyFeedDefs.PostView | undefined { + const queryDatas = queryClient.getQueriesData>({ + queryKey: ['notification-feed'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const item of page.items) { + if (item.subject?.uri === uri) { + return item.subject + } + } + } + } + return undefined +} + function groupNotifications( notifs: AppBskyNotificationListNotifications.Notification[], ): FeedNotification[] { diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 5f81cb44..1334461c 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,6 +1,12 @@ import {useCallback, useMemo} from 'react' import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api' -import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' +import { + useInfiniteQuery, + InfiniteData, + QueryKey, + QueryClient, + useQueryClient, +} from '@tanstack/react-query' import {getAgent} from '../session' import {useFeedTuners} from '../preferences/feed-tuners' import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip' @@ -14,6 +20,7 @@ import {MergeFeedAPI} from 'lib/api/feed/merge' import {useModerationOpts} from '#/state/queries/preferences' import {logger} from '#/logger' import {STALE} from '#/state/queries' +import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri' type ActorDid = string type AuthorFilter = @@ -66,6 +73,7 @@ export function usePostFeedQuery( params?: FeedParams, opts?: {enabled?: boolean}, ) { + const queryClient = useQueryClient() const feedTuners = useFeedTuners(feedDesc) const enabled = opts?.enabled !== false const moderationOpts = useModerationOpts() @@ -141,6 +149,7 @@ export function usePostFeedQuery( tuner.reset() } const res = await api.fetch({cursor: pageParam, limit: 30}) + precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution const slices = tuner.tune(res.feed, feedTuners) return { cursor: res.cursor, @@ -152,7 +161,6 @@ export function usePostFeedQuery( slice.items.every( item => item.post.author.did === slice.items[0].post.author.did, ), - source: undefined, // TODO items: slice.items .map((item, i) => { if ( @@ -180,3 +188,31 @@ export function usePostFeedQuery( return {...out, pollLatest} } + +/** + * This helper is used by the post-thread placeholder function to + * find a post in the query-data cache + */ +export function findPostInQueryData( + queryClient: QueryClient, + uri: string, +): FeedPostSliceItem | undefined { + const queryDatas = queryClient.getQueriesData>({ + queryKey: ['post-feed'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const slice of page.slices) { + for (const item of slice.items) { + if (item.uri === uri) { + return item + } + } + } + } + } + return undefined +} diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index b4a474ea..c616b05c 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -3,11 +3,17 @@ import { AppBskyFeedPost, AppBskyFeedGetPostThread, } from '@atproto/api' -import {useQuery} from '@tanstack/react-query' +import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query' import {getAgent} from '#/state/session' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {STALE} from '#/state/queries' +import { + findPostInQueryData as findPostInFeedQueryData, + FeedPostSliceItem, +} from './post-feed' +import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed' +import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri' export const RQKEY = (uri: string) => ['post-thread', uri] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] @@ -18,6 +24,8 @@ export interface ThreadCtx { hasMore?: boolean showChildReplyLine?: boolean showParentReplyLine?: boolean + isParentLoading?: boolean + isChildLoading?: boolean } export type ThreadPost = { @@ -58,17 +66,44 @@ export type ThreadNode = | ThreadUnknown export function usePostThreadQuery(uri: string | undefined) { + const queryClient = useQueryClient() return useQuery({ staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(uri || ''), async queryFn() { const res = await getAgent().getPostThread({uri: uri!}) if (res.success) { - return responseToThreadNodes(res.data.thread) + const nodes = responseToThreadNodes(res.data.thread) + precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution + return nodes } return {type: 'unknown', uri: uri!} }, enabled: !!uri, + placeholderData: () => { + if (!uri) { + return undefined + } + { + const item = findPostInQueryData(queryClient, uri) + if (item) { + return threadNodeToPlaceholderThread(item) + } + } + { + const item = findPostInFeedQueryData(queryClient, uri) + if (item) { + return feedItemToPlaceholderThread(item) + } + } + { + const item = findPostInNotifsQueryData(queryClient, uri) + if (item) { + return postViewToPlaceholderThread(item) + } + } + return undefined + }, }) } @@ -178,3 +213,110 @@ function responseToThreadNodes( return {type: 'unknown', uri: ''} } } + +function findPostInQueryData( + queryClient: QueryClient, + uri: string, +): ThreadNode | undefined { + const queryDatas = queryClient.getQueriesData({ + queryKey: ['post-thread'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData) { + continue + } + for (const item of traverseThread(queryData)) { + if (item.uri === uri) { + return item + } + } + } + return undefined +} + +function* traverseThread(node: ThreadNode): Generator { + if (node.type === 'post') { + if (node.parent) { + yield* traverseThread(node.parent) + } + yield node + if (node.replies?.length) { + for (const reply of node.replies) { + yield* traverseThread(reply) + } + } + } +} + +function threadNodeToPlaceholderThread( + node: ThreadNode, +): ThreadNode | undefined { + if (node.type !== 'post') { + return undefined + } + return { + type: node.type, + _reactKey: node._reactKey, + uri: node.uri, + post: node.post, + record: node.record, + parent: undefined, + replies: undefined, + viewer: node.viewer, + ctx: { + depth: 0, + isHighlightedPost: true, + hasMore: false, + showChildReplyLine: false, + showParentReplyLine: false, + isParentLoading: !!node.record.reply, + isChildLoading: !!node.post.replyCount, + }, + } +} + +function feedItemToPlaceholderThread(item: FeedPostSliceItem): ThreadNode { + return { + type: 'post', + _reactKey: item.post.uri, + uri: item.post.uri, + post: item.post, + record: item.record, + parent: undefined, + replies: undefined, + viewer: item.post.viewer, + ctx: { + depth: 0, + isHighlightedPost: true, + hasMore: false, + showChildReplyLine: false, + showParentReplyLine: false, + isParentLoading: !!item.record.reply, + isChildLoading: !!item.post.replyCount, + }, + } +} + +function postViewToPlaceholderThread( + post: AppBskyFeedDefs.PostView, +): ThreadNode { + return { + type: 'post', + _reactKey: post.uri, + uri: post.uri, + post: post, + record: post.record as AppBskyFeedPost.Record, // validate in notifs + parent: undefined, + replies: undefined, + viewer: post.viewer, + ctx: { + depth: 0, + isHighlightedPost: true, + hasMore: false, + showChildReplyLine: false, + showParentReplyLine: false, + isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply, + isChildLoading: !!post.replyCount, + }, + } +} diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index dc8e7fbe..05a9f4b1 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -1,27 +1,76 @@ -import {useQuery} from '@tanstack/react-query' -import {AtUri} from '@atproto/api' +import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query' +import {AtUri, AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' import {getAgent} from '#/state/session' import {STALE} from '#/state/queries' +import {ThreadNode} from './post-thread' -export const RQKEY = (uri: string) => ['resolved-uri', uri] +export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle] -export function useResolveUriQuery(uri: string | undefined) { - return useQuery<{uri: string; did: string}, Error>({ - staleTime: STALE.INFINITY, - queryKey: RQKEY(uri || ''), - async queryFn() { - const urip = new AtUri(uri || '') - if (!urip.host.startsWith('did:')) { - const res = await getAgent().resolveHandle({handle: urip.host}) - urip.host = res.data.did - } - return {did: urip.host, uri: urip.toString()} - }, - enabled: !!uri, - }) +type UriUseQueryResult = UseQueryResult<{did: string; uri: string}, Error> +export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult { + const urip = new AtUri(uri || '') + const res = useResolveDidQuery(urip.host) + if (res.data) { + urip.host = res.data + return { + ...res, + data: {did: urip.host, uri: urip.toString()}, + } as UriUseQueryResult + } + return res as UriUseQueryResult } export function useResolveDidQuery(didOrHandle: string | undefined) { - return useResolveUriQuery(didOrHandle ? `at://${didOrHandle}/` : undefined) + return useQuery({ + staleTime: STALE.INFINITY, + queryKey: RQKEY(didOrHandle || ''), + async queryFn() { + if (!didOrHandle) { + return '' + } + if (!didOrHandle.startsWith('did:')) { + const res = await getAgent().resolveHandle({handle: didOrHandle}) + didOrHandle = res.data.did + } + return didOrHandle + }, + enabled: !!didOrHandle, + }) +} + +export function precacheProfile( + queryClient: QueryClient, + profile: + | AppBskyActorDefs.ProfileView + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileViewDetailed, +) { + queryClient.setQueryData(RQKEY(profile.handle), profile.did) +} + +export function precacheFeedPosts( + queryClient: QueryClient, + posts: AppBskyFeedDefs.FeedViewPost[], +) { + for (const post of posts) { + precacheProfile(queryClient, post.post.author) + } +} + +export function precacheThreadPosts( + queryClient: QueryClient, + node: ThreadNode, +) { + if (node.type === 'post') { + precacheProfile(queryClient, node.post.author) + if (node.parent) { + precacheThreadPosts(queryClient, node.parent) + } + if (node.replies?.length) { + for (const reply of node.replies) { + precacheThreadPosts(queryClient, reply) + } + } + } } diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index c1983394..edf02e9c 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -39,8 +39,10 @@ import { usePreferencesQuery, } from '#/state/queries/preferences' import {useSession} from '#/state/session' +import {isNative} from '#/platform/detection' +import {logger} from '#/logger' -// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO +const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} const TOP_COMPONENT = {_reactKey: '__top_component__'} const PARENT_SPINNER = {_reactKey: '__parent_spinner__'} @@ -72,7 +74,6 @@ export function PostThread({ isError, error, refetch, - isRefetching, data: thread, } = usePostThreadQuery(uri) const {data: preferences} = usePreferencesQuery() @@ -110,7 +111,6 @@ export function PostThread({ return ( void onPressReply: () => void @@ -136,29 +134,15 @@ function PostThreadLoaded({ const pal = usePalette('default') const {isTablet, isDesktop} = useWebMediaQueries() const ref = useRef(null) - // const hasScrolledIntoView = useRef(false) TODO + const highlightedPostRef = useRef(null) + const needsScrollAdjustment = useRef( + !isNative || // web always uses scroll adjustment + (thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder + ) const [maxVisible, setMaxVisible] = React.useState(100) + const [isPTRing, setIsPTRing] = React.useState(false) - // TODO - // const posts = React.useMemo(() => { - // if (view.thread) { - // let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread))) - // if (arr.length > maxVisible) { - // arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) - // } - // if (view.isLoadingFromCache) { - // if (view.thread?.postRecord?.reply) { - // arr.unshift(PARENT_SPINNER) - // } - // arr.push(CHILD_SPINNER) - // } else { - // arr.push(BOTTOM_COMPONENT) - // } - // return arr - // } - // return [] - // }, [view.isLoadingFromCache, view.thread, maxVisible]) - // const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost) + // construct content const posts = React.useMemo(() => { let arr = [TOP_COMPONENT].concat( Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))), @@ -166,54 +150,61 @@ function PostThreadLoaded({ if (arr.length > maxVisible) { arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) } - arr.push(BOTTOM_COMPONENT) + if (arr.indexOf(CHILD_SPINNER) === -1) { + arr.push(BOTTOM_COMPONENT) + } return arr }, [thread, maxVisible, threadViewPrefs]) - // TODO - /*const onContentSizeChange = React.useCallback(() => { + /** + * NOTE + * Scroll positioning + * + * This callback is run if needsScrollAdjustment.current == true, which is... + * - On web: always + * - On native: when the placeholder cache is not being used + * + * It then only runs when viewing a reply, and the goal is to scroll the + * reply into view. + * + * On native, if the placeholder cache is being used then maintainVisibleContentPosition + * is a more effective solution, so we use that. Otherwise, typically we're loading from + * the react-query cache, so we just need to immediately scroll down to the post. + * + * On desktop, maintainVisibleContentPosition isn't supported so we just always use + * this technique. + * + * -prf + */ + const onContentSizeChange = React.useCallback(() => { // only run once - if (hasScrolledIntoView.current) { + if (!needsScrollAdjustment.current) { return } // wait for loading to finish - if ( - !view.hasContent || - (view.isFromCache && view.isLoadingFromCache) || - view.isLoading - ) { - return + if (thread.type === 'post' && !!thread.parent) { + highlightedPostRef.current?.measure( + (_x, _y, _width, _height, _pageX, pageY) => { + ref.current?.scrollToOffset({ + animated: false, + offset: pageY - (isDesktop ? 0 : 50), + }) + }, + ) + needsScrollAdjustment.current = false } + }, [thread, isDesktop]) - if (highlightedPostIndex !== -1) { - ref.current?.scrollToIndex({ - index: highlightedPostIndex, - animated: false, - viewPosition: 0, - }) - hasScrolledIntoView.current = true + const onPTR = React.useCallback(async () => { + setIsPTRing(true) + try { + await onRefresh() + } catch (err) { + logger.error('Failed to refresh posts thread', {error: err}) } - }, [ - highlightedPostIndex, - view.hasContent, - view.isFromCache, - view.isLoadingFromCache, - view.isLoading, - ])*/ - const onScrollToIndexFailed = React.useCallback( - (info: { - index: number - highestMeasuredFrameIndex: number - averageItemLength: number - }) => { - ref.current?.scrollToOffset({ - animated: false, - offset: info.averageItemLength * info.index, - }) - }, - [ref], - ) + setIsPTRing(false) + }, [setIsPTRing, onRefresh]) const renderItem = React.useCallback( ({item, index}: {item: YieldedItem; index: number}) => { @@ -290,18 +281,21 @@ function PostThreadLoaded({ ? (posts[index - 1] as ThreadPost) : undefined return ( - + + + ) } return null @@ -330,25 +324,21 @@ function PostThreadLoaded({ data={posts} initialNumToRender={posts.length} maintainVisibleContentPosition={ - undefined // TODO - // isNative && view.isFromCache && view.isCachedPostAReply - // ? MAINTAIN_VISIBLE_CONTENT_POSITION - // : undefined + !needsScrollAdjustment.current + ? MAINTAIN_VISIBLE_CONTENT_POSITION + : undefined } keyExtractor={item => item._reactKey} renderItem={renderItem} refreshControl={ } - onContentSizeChange={ - undefined //TODOisNative && view.isFromCache ? undefined : onContentSizeChange - } - onScrollToIndexFailed={onScrollToIndexFailed} + onContentSizeChange={onContentSizeChange} style={s.hContentRegion} // @ts-ignore our .web version only -prf desktopFixedHeight @@ -465,6 +455,8 @@ function* flattenThreadSkeleton( if (node.type === 'post') { if (node.parent) { yield* flattenThreadSkeleton(node.parent) + } else if (node.ctx.isParentLoading) { + yield PARENT_SPINNER } yield node if (node.ctx.isHighlightedPost) { @@ -474,6 +466,8 @@ function* flattenThreadSkeleton( for (const reply of node.replies) { yield* flattenThreadSkeleton(reply) } + } else if (node.ctx.isChildLoading) { + yield CHILD_SPINNER } } else if (node.type === 'not-found') { yield DELETED diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 45c1b3ad..d94f5103 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -28,7 +28,7 @@ export function ProfileFollowers({name}: {name: string}) { isError, error, refetch, - } = useProfileFollowersQuery(resolvedDid?.did) + } = useProfileFollowersQuery(resolvedDid) const followers = React.useMemo(() => { if (data?.pages) { diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index e1dce78a..890c13eb 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -28,7 +28,7 @@ export function ProfileFollows({name}: {name: string}) { isError, error, refetch, - } = useProfileFollowsQuery(resolvedDid?.did) + } = useProfileFollowsQuery(resolvedDid) const follows = React.useMemo(() => { if (data?.pages) { diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index c3bc598a..7ddcf17a 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -58,7 +58,7 @@ export function ProfileScreen({route}: Props) { refetch: refetchProfile, isFetching: isFetchingProfile, } = useProfileQuery({ - did: resolvedDid?.did, + did: resolvedDid, }) const onPressTryAgain = React.useCallback(() => {