From 0501c2be778b1a8517da6ea4111bcbd56dc056ed Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 13 Nov 2023 15:12:41 -0800 Subject: [PATCH] Profile cleanup (react-query refactor) (#1891) * Only fetch profile tab content when focused * Fix keys * Add missing behaviors to post tabs * Delete old profile mobx model --- src/state/models/content/profile.ts | 306 ------------------------- src/state/queries/profile-feedgens.ts | 7 +- src/state/queries/profile-lists.ts | 4 +- src/view/com/feeds/ProfileFeedgens.tsx | 7 +- src/view/com/lists/ProfileLists.tsx | 5 +- src/view/com/pager/PagerWithHeader.tsx | 5 + src/view/screens/Profile.tsx | 53 +++-- 7 files changed, 60 insertions(+), 327 deletions(-) delete mode 100644 src/state/models/content/profile.ts diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts deleted file mode 100644 index 2abb9bfb5f..0000000000 --- a/src/state/models/content/profile.ts +++ /dev/null @@ -1,306 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AtUri, - ComAtprotoLabelDefs, - AppBskyGraphDefs, - AppBskyActorGetProfile as GetProfile, - AppBskyActorProfile, - RichText, - moderateProfile, - ProfileModeration, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import * as apilib from 'lib/api/index' -import {cleanError} from 'lib/strings/errors' -import {FollowState} from '../cache/my-follows' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -export class ProfileViewerModel { - muted?: boolean - mutedByList?: AppBskyGraphDefs.ListViewBasic - following?: string - followedBy?: string - blockedBy?: boolean - blocking?: string - blockingByList?: AppBskyGraphDefs.ListViewBasic; - [key: string]: unknown - - constructor() { - makeAutoObservable(this) - } -} - -export class ProfileModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - params: GetProfile.QueryParams - - // data - did: string = '' - handle: string = '' - creator: string = '' - displayName?: string = '' - description?: string = '' - avatar?: string = '' - banner?: string = '' - followersCount: number = 0 - followsCount: number = 0 - postsCount: number = 0 - labels?: ComAtprotoLabelDefs.Label[] = undefined - viewer = new ProfileViewerModel(); - [key: string]: unknown - - // added data - descriptionRichText?: RichText = new RichText({text: ''}) - - constructor( - public rootStore: RootStoreModel, - params: GetProfile.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.did !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get moderation(): ProfileModeration { - return moderateProfile(this, this.rootStore.preferences.moderationOpts) - } - - // public api - // = - - async setup() { - const precache = await this.rootStore.profiles.cache.get(this.params.actor) - if (precache) { - await this._loadWithCache(precache) - } else { - await this._load() - } - } - - async refresh() { - await this._load(true) - } - - async toggleFollowing() { - if (!this.rootStore.me.did) { - throw new Error('Not logged in') - } - - const follows = this.rootStore.me.follows - const followUri = - (await follows.fetchFollowState(this.did)) === FollowState.Following - ? follows.getFollowUri(this.did) - : undefined - - // guard against this view getting out of sync with the follows cache - if (followUri !== this.viewer.following) { - this.viewer.following = followUri - return - } - - if (followUri) { - // unfollow - await this.rootStore.agent.deleteFollow(followUri) - runInAction(() => { - this.followersCount-- - this.viewer.following = undefined - this.rootStore.me.follows.removeFollow(this.did) - }) - track('Profile:Unfollow', { - username: this.handle, - }) - } else { - // follow - const res = await this.rootStore.agent.follow(this.did) - runInAction(() => { - this.followersCount++ - this.viewer.following = res.uri - this.rootStore.me.follows.hydrate(this.did, this) - }) - track('Profile:Follow', { - username: this.handle, - }) - } - } - - async updateProfile( - updates: AppBskyActorProfile.Record, - newUserAvatar: RNImage | undefined | null, - newUserBanner: RNImage | undefined | null, - ) { - await this.rootStore.agent.upsertProfile(async existing => { - existing = existing || {} - existing.displayName = updates.displayName - existing.description = updates.description - if (newUserAvatar) { - const res = await apilib.uploadBlob( - this.rootStore.agent, - newUserAvatar.path, - newUserAvatar.mime, - ) - existing.avatar = res.data.blob - } else if (newUserAvatar === null) { - existing.avatar = undefined - } - if (newUserBanner) { - const res = await apilib.uploadBlob( - this.rootStore.agent, - newUserBanner.path, - newUserBanner.mime, - ) - existing.banner = res.data.blob - } else if (newUserBanner === null) { - existing.banner = undefined - } - return existing - }) - await this.rootStore.me.load() - await this.refresh() - } - - async muteAccount() { - await this.rootStore.agent.mute(this.did) - this.viewer.muted = true - await this.refresh() - } - - async unmuteAccount() { - await this.rootStore.agent.unmute(this.did) - this.viewer.muted = false - await this.refresh() - } - - async blockAccount() { - const res = await this.rootStore.agent.app.bsky.graph.block.create( - { - repo: this.rootStore.me.did, - }, - { - subject: this.did, - createdAt: new Date().toISOString(), - }, - ) - this.viewer.blocking = res.uri - await this.refresh() - } - - async unblockAccount() { - if (!this.viewer.blocking) { - return - } - const {rkey} = new AtUri(this.viewer.blocking) - await this.rootStore.agent.app.bsky.graph.block.delete({ - repo: this.rootStore.me.did, - rkey, - }) - this.viewer.blocking = undefined - await this.refresh() - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch profile', {error: err}) - } - } - - // loader functions - // = - - async _load(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const res = await this.rootStore.agent.getProfile(this.params) - this.rootStore.profiles.overwrite(this.params.actor, res) - if (res.data.handle) { - this.rootStore.handleResolutions.cache.set( - res.data.handle, - res.data.did, - ) - } - this._replaceAll(res) - await this._createRichText() - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - async _loadWithCache(precache: GetProfile.Response) { - // use cached value - this._replaceAll(precache) - await this._createRichText() - this._xIdle() - - // fetch latest - try { - const res = await this.rootStore.agent.getProfile(this.params) - this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation - this._replaceAll(res) - await this._createRichText() - } catch (e: any) { - this._xIdle(e) - } - } - - _replaceAll(res: GetProfile.Response) { - this.did = res.data.did - this.handle = res.data.handle - this.displayName = res.data.displayName - this.description = res.data.description - this.avatar = res.data.avatar - this.banner = res.data.banner - this.followersCount = res.data.followersCount || 0 - this.followsCount = res.data.followsCount || 0 - this.postsCount = res.data.postsCount || 0 - this.labels = res.data.labels - if (res.data.viewer) { - Object.assign(this.viewer, res.data.viewer) - } - this.rootStore.me.follows.hydrate(this.did, res.data) - } - - async _createRichText() { - this.descriptionRichText = new RichText( - {text: this.description || ''}, - {cleanNewlines: true}, - ) - await this.descriptionRichText.detectFacets(this.rootStore.agent) - } -} diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts index 652a123d94..fc0c91c275 100644 --- a/src/state/queries/profile-feedgens.ts +++ b/src/state/queries/profile-feedgens.ts @@ -7,8 +7,12 @@ type RQPageParam = string | undefined export const RQKEY = (did: string) => ['profile-feedgens', did] -export function useProfileFeedgensQuery(did: string) { +export function useProfileFeedgensQuery( + did: string, + opts?: {enabled?: boolean}, +) { const {agent} = useSession() + const enabled = opts?.enabled !== false return useInfiniteQuery< AppBskyFeedGetActorFeeds.OutputSchema, Error, @@ -27,5 +31,6 @@ export function useProfileFeedgensQuery(did: string) { }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, + enabled, }) } diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index a277a6d61d..7d36af28c0 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -7,8 +7,9 @@ type RQPageParam = string | undefined export const RQKEY = (did: string) => ['profile-lists', did] -export function useProfileListsQuery(did: string) { +export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { const {agent} = useSession() + const enabled = opts?.enabled !== false return useInfiniteQuery< AppBskyGraphGetLists.OutputSchema, Error, @@ -27,5 +28,6 @@ export function useProfileListsQuery(did: string) { }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, + enabled, }) } diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 2cc688c50b..a3c914595d 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -35,6 +35,7 @@ export function ProfileFeedgens({ onScroll, scrollEventThrottle, headerOffset, + enabled, style, testID, }: { @@ -43,12 +44,14 @@ export function ProfileFeedgens({ onScroll?: OnScrollHandler scrollEventThrottle?: number headerOffset: number + enabled?: boolean style?: StyleProp testID?: string }) { const pal = usePalette('default') const theme = useTheme() const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) const { data, isFetching, @@ -58,7 +61,7 @@ export function ProfileFeedgens({ isError, error, refetch, - } = useProfileFeedgensQuery(did) + } = useProfileFeedgensQuery(did, opts) const isEmpty = !isFetching && !data?.pages[0]?.feeds.length const {data: preferences} = usePreferencesQuery() @@ -163,7 +166,7 @@ export function ProfileFeedgens({ testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} data={items} - keyExtractor={(item: any) => item._reactKey} + keyExtractor={(item: any) => item._reactKey || item.uri} renderItem={renderItemInner} refreshControl={ testID?: string }) { @@ -49,6 +51,7 @@ export function ProfileLists({ const theme = useTheme() const {track} = useAnalytics() const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) const { data, isFetching, @@ -58,7 +61,7 @@ export function ProfileLists({ isError, error, refetch, - } = useProfileListsQuery(did) + } = useProfileListsQuery(did, opts) const isEmpty = !isFetching && !data?.pages[0]?.lists.length const items = React.useMemo(() => { diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 95798d26b8..cb9b780a80 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -24,6 +24,7 @@ const SCROLLED_DOWN_LIMIT = 200 interface PagerWithHeaderChildParams { headerHeight: number + isFocused: boolean onScroll: OnScrollHandler isScrolledDown: boolean scrollElRef: React.MutableRefObject | ScrollView | null> @@ -202,6 +203,7 @@ export const PagerWithHeader = React.forwardRef( ) => registerRef(r, i)} @@ -218,12 +220,14 @@ export const PagerWithHeader = React.forwardRef( function PagerItem({ headerHeight, isReady, + isFocused, isScrolledDown, onScrollWorklet, renderTab, registerRef, }: { headerHeight: number + isFocused: boolean isReady: boolean isScrolledDown: boolean registerRef: (scrollRef: AnimatedRef) => void @@ -244,6 +248,7 @@ function PagerItem({ return renderTab({ headerHeight, + isFocused, isScrolledDown, onScroll: scrollHandler, scrollElRef: scrollElRef as React.MutableRefObject< diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index b5b5f33d04..1a2982027e 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -31,8 +31,11 @@ import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import {useModerationOpts} from '#/state/queries/preferences' import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {cleanError} from '#/lib/strings/errors' +import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' +import {useQueryClient} from '@tanstack/react-query' type Props = NativeStackScreenProps export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({ @@ -224,67 +227,79 @@ function ProfileScreenLoaded({ items={sectionTitles} onPageSelected={onPageSelected} renderHeader={renderHeader}> - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( )} - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( )} - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( )} {showLikesTab - ? ({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( ) : null} {showFeedsTab - ? ({onScroll, headerHeight, scrollElRef}) => ( + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( ) : null} {showListsTab - ? ({onScroll, headerHeight, scrollElRef}) => ( + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( ) : null} @@ -305,26 +320,23 @@ interface FeedSectionProps { feed: FeedDescriptor onScroll: OnScrollHandler headerHeight: number + isFocused: boolean isScrolledDown: boolean scrollElRef: any /* TODO */ } const FeedSection = React.forwardRef( function FeedSectionImpl( - { - feed, - onScroll, - headerHeight, - // isScrolledDown, - scrollElRef, - }, + {feed, onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}, ref, ) { - // const hasNew = false //TODO feed.hasNewLatest && !feed.isRefreshing + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) const onScrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - // feed.refresh() TODO - }, [scrollElRef, headerHeight]) + queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, })) @@ -339,11 +351,20 @@ const FeedSection = React.forwardRef( testID="postsFeed" feed={feed} scrollElRef={scrollElRef} + onHasNew={setHasNew} onScroll={onScroll} scrollEventThrottle={1} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} + enabled={isFocused} /> + {(isScrolledDown || hasNew) && ( + + )} ) },