Port Profile Followers/Follows to RQ (#1893)

* Port user followers to RQ

* Port user follows to RQ

* Start porting FollowButton to RQ

* Fix RQ key

* Check pending

* Fix shadow and pending states

* Rm unused

* Remove last usage of useFollowProfile
zio/stable
dan 2023-11-15 01:55:54 +00:00 committed by GitHub
parent d1cb74febe
commit e699df21c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 370 additions and 485 deletions

View File

@ -1,55 +0,0 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {useStores} from 'state/index'
import {FollowState} from 'state/models/cache/my-follows'
import {logger} from '#/logger'
export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
const store = useStores()
const state = store.me.follows.getFollowState(profile.did)
return {
state,
following: state === FollowState.Following,
toggle: React.useCallback(async () => {
if (state === FollowState.Following) {
try {
await store.agent.deleteFollow(
store.me.follows.getFollowUri(profile.did),
)
store.me.follows.removeFollow(profile.did)
return {
state: FollowState.NotFollowing,
following: false,
}
} catch (e: any) {
logger.error('Failed to delete follow', {error: e})
throw e
}
} else if (state === FollowState.NotFollowing) {
try {
const res = await store.agent.follow(profile.did)
store.me.follows.addFollow(profile.did, {
followRecordUri: res.uri,
did: profile.did,
handle: profile.handle,
displayName: profile.displayName,
avatar: profile.avatar,
})
return {
state: FollowState.Following,
following: true,
}
} catch (e: any) {
logger.error('Failed to create follow', {error: e})
throw e
}
}
return {
state: FollowState.Unknown,
following: false,
}
}, [store, profile, state]),
}
}

View File

@ -1,121 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetFollowers as GetFollowers,
AppBskyActorDefs as ActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export type FollowerItem = ActorDefs.ProfileViewBasic
export class UserFollowersModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: GetFollowers.QueryParams
hasMore = true
loadMoreCursor?: string
// data
subject: ActorDefs.ProfileViewBasic = {
did: '',
handle: '',
}
followers: FollowerItem[] = []
constructor(
public rootStore: RootStoreModel,
params: GetFollowers.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.subject.did !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.agent.getFollowers(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// 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 user followers', {error: err})
}
}
// helper functions
// =
_replaceAll(res: GetFollowers.Response) {
this.followers = []
this._appendAll(res)
}
_appendAll(res: GetFollowers.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.followers = this.followers.concat(res.data.followers)
this.rootStore.me.follows.hydrateMany(res.data.followers)
}
}

View File

@ -1,121 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetFollows as GetFollows,
AppBskyActorDefs as ActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export type FollowItem = ActorDefs.ProfileViewBasic
export class UserFollowsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: GetFollows.QueryParams
hasMore = true
loadMoreCursor?: string
// data
subject: ActorDefs.ProfileViewBasic = {
did: '',
handle: '',
}
follows: FollowItem[] = []
constructor(
public rootStore: RootStoreModel,
params: GetFollows.QueryParams,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.subject.did !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
const res = await this.rootStore.agent.getFollows(params)
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// 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 user follows', err)
}
}
// helper functions
// =
_replaceAll(res: GetFollows.Response) {
this.follows = []
this._appendAll(res)
}
_appendAll(res: GetFollows.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.follows = this.follows.concat(res.data.follows)
this.rootStore.me.follows.hydrateMany(res.data.follows)
}
}

View File

@ -0,0 +1,32 @@
import {AppBskyGraphGetFollowers} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {useSession} from '../session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
export const RQKEY = (did: string) => ['profile-followers', did]
export function useProfileFollowersQuery(did: string | undefined) {
const {agent} = useSession()
return useInfiniteQuery<
AppBskyGraphGetFollowers.OutputSchema,
Error,
InfiniteData<AppBskyGraphGetFollowers.OutputSchema>,
QueryKey,
RQPageParam
>({
queryKey: RQKEY(did || ''),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await agent.app.bsky.graph.getFollowers({
actor: did || '',
limit: PAGE_SIZE,
cursor: pageParam,
})
return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled: !!did,
})
}

View File

@ -0,0 +1,32 @@
import {AppBskyGraphGetFollows} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {useSession} from '../session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
export const RQKEY = (did: string) => ['profile-follows', did]
export function useProfileFollowsQuery(did: string | undefined) {
const {agent} = useSession()
return useInfiniteQuery<
AppBskyGraphGetFollows.OutputSchema,
Error,
InfiniteData<AppBskyGraphGetFollows.OutputSchema>,
QueryKey,
RQPageParam
>({
queryKey: RQKEY(did || ''),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await agent.app.bsky.graph.getFollows({
actor: did || '',
limit: PAGE_SIZE,
cursor: pageParam,
})
return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled: !!did,
})
}

View File

@ -1,7 +1,12 @@
import {AppBskyActorGetSuggestions, moderateProfile} from '@atproto/api' import {
AppBskyActorGetSuggestions,
AppBskyGraphGetSuggestedFollowsByActor,
moderateProfile,
} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
useQuery,
InfiniteData, InfiniteData,
QueryKey, QueryKey,
} from '@tanstack/react-query' } from '@tanstack/react-query'
@ -9,7 +14,11 @@ import {
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useModerationOpts} from '#/state/queries/preferences' import {useModerationOpts} from '#/state/queries/preferences'
export const suggestedFollowsQueryKey = ['suggested-follows'] const suggestedFollowsQueryKey = ['suggested-follows']
const suggestedFollowsByActorQuery = (did: string) => [
'suggested-follows-by-actor',
did,
]
export function useSuggestedFollowsQuery() { export function useSuggestedFollowsQuery() {
const {agent, currentAccount} = useSession() const {agent, currentAccount} = useSession()
@ -60,6 +69,21 @@ export function useSuggestedFollowsQuery() {
}) })
} }
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const {agent} = useSession()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
queryKey: suggestedFollowsByActorQuery(did),
queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: did,
})
return res.data
},
})
}
// TODO: Delete and replace usages with the one above.
export function useGetSuggestedFollowersByActor() { export function useGetSuggestedFollowersByActor() {
const {agent} = useSession() const {agent} = useSession()

View File

@ -1,47 +1,76 @@
import React from 'react' import React from 'react'
import {StyleProp, TextStyle, View} from 'react-native' import {StyleProp, TextStyle, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {Button, ButtonType} from '../util/forms/Button' import {Button, ButtonType} from '../util/forms/Button'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {FollowState} from 'state/models/cache/my-follows' import {
import {useFollowProfile} from 'lib/hooks/useFollowProfile' useProfileFollowMutation,
useProfileUnfollowMutation,
} from '#/state/queries/profile'
import {Shadow} from '#/state/cache/types'
export const FollowButton = observer(function FollowButtonImpl({ export function FollowButton({
unfollowedType = 'inverted', unfollowedType = 'inverted',
followedType = 'default', followedType = 'default',
profile, profile,
onToggleFollow,
labelStyle, labelStyle,
}: { }: {
unfollowedType?: ButtonType unfollowedType?: ButtonType
followedType?: ButtonType followedType?: ButtonType
profile: AppBskyActorDefs.ProfileViewBasic profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
onToggleFollow?: (v: boolean) => void
labelStyle?: StyleProp<TextStyle> labelStyle?: StyleProp<TextStyle>
}) { }) {
const {state, following, toggle} = useFollowProfile(profile) const followMutation = useProfileFollowMutation()
const unfollowMutation = useProfileUnfollowMutation()
const onPress = React.useCallback(async () => { const onPressFollow = async () => {
try { if (profile.viewer?.following) {
const {following} = await toggle() return
onToggleFollow?.(following) }
} catch (e: any) { try {
Toast.show('An issue occurred, please try again.') await followMutation.mutateAsync({did: profile.did})
} catch (e: any) {
Toast.show(`An issue occurred, please try again.`)
}
} }
}, [toggle, onToggleFollow])
if (state === FollowState.Unknown) { const onPressUnfollow = async () => {
if (!profile.viewer?.following) {
return
}
try {
await unfollowMutation.mutateAsync({
did: profile.did,
followUri: profile.viewer?.following,
})
} catch (e: any) {
Toast.show(`An issue occurred, please try again.`)
}
}
if (!profile.viewer) {
return <View /> return <View />
} }
if (profile.viewer.following) {
return ( return (
<Button <Button
type={following ? followedType : unfollowedType} type={followedType}
labelStyle={labelStyle} labelStyle={labelStyle}
onPress={onPress} onPress={onPressUnfollow}
label={following ? 'Unfollow' : 'Follow'} label="Unfollow"
withLoading={true} withLoading={true}
/> />
) )
}) } else {
return (
<Button
type={unfollowedType}
labelStyle={labelStyle}
onPress={onPressFollow}
label="Follow"
withLoading={true}
/>
)
}
}

View File

@ -21,8 +21,10 @@ import {
getProfileModerationCauses, getProfileModerationCauses,
getModerationCauseKey, getModerationCauseKey,
} from 'lib/moderation' } from 'lib/moderation'
import {Shadow} from '#/state/cache/types'
import {useModerationOpts} from '#/state/queries/preferences' import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
export function ProfileCard({ export function ProfileCard({
testID, testID,
@ -40,7 +42,9 @@ export function ProfileCard({
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode renderButton?: (
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
) => React.ReactNode
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
@ -188,20 +192,21 @@ const FollowersList = observer(function FollowersListImpl({
) )
}) })
export const ProfileCardWithFollowBtn = observer( export function ProfileCardWithFollowBtn({
function ProfileCardWithFollowBtnImpl({
profile, profile,
noBg, noBg,
noBorder, noBorder,
followers, followers,
}: { dataUpdatedAt,
}: {
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
}) { dataUpdatedAt: number
const store = useStores() }) {
const isMe = store.me.did === profile.did const {currentAccount} = useSession()
const isMe = profile.did === currentAccount?.did
return ( return (
<ProfileCard <ProfileCard
@ -210,12 +215,14 @@ export const ProfileCardWithFollowBtn = observer(
noBorder={noBorder} noBorder={noBorder}
followers={followers} followers={followers}
renderButton={ renderButton={
isMe ? undefined : () => <FollowButton profile={profile} /> isMe
? undefined
: profileShadow => <FollowButton profile={profileShadow} />
} }
dataUpdatedAt={dataUpdatedAt}
/> />
) )
}, }
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {

View File

@ -1,49 +1,73 @@
import React, {useEffect} from 'react' import React from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import { import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
UserFollowersModel,
FollowerItem,
} from 'state/models/lists/user-followers'
import {CenteredView, FlatList} from '../util/Views' import {CenteredView, FlatList} from '../util/Views'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from './ProfileCard' import {ProfileCardWithFollowBtn} from './ProfileCard'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {logger} from '#/logger' import {logger} from '#/logger'
import {cleanError} from '#/lib/strings/errors'
export const ProfileFollowers = observer(function ProfileFollowers({ export function ProfileFollowers({name}: {name: string}) {
name,
}: {
name: string
}) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const [isPTRing, setIsPTRing] = React.useState(false)
const view = React.useMemo( const {
() => new UserFollowersModel(store, {actor: name}), data: resolvedDid,
[store, name], error: resolveError,
) isFetching: isFetchingDid,
} = useResolveDidQuery(name)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = useProfileFollowersQuery(resolvedDid?.did)
useEffect(() => { const followers = React.useMemo(() => {
view if (data?.pages) {
.loadMore() return data.pages.flatMap(page => page.followers)
.catch(err => }
logger.error('Failed to fetch user followers', {error: err}), }, [data])
)
}, [view]) const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
const onRefresh = () => { try {
view.refresh() await refetch()
} catch (err) {
logger.error('Failed to refresh followers', {error: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = async () => {
if (isFetching || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more followers', {error: err})
} }
const onEndReached = () => {
view.loadMore().catch(err =>
logger.error('Failed to load more followers', {
error: err,
}),
)
} }
if (!view.hasLoaded) { const renderItem = React.useCallback(
({item}: {item: ActorDefs.ProfileViewBasic}) => (
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
),
[dataUpdatedAt],
)
if (isFetchingDid || !isFetched) {
return ( return (
<CenteredView> <CenteredView>
<ActivityIndicator /> <ActivityIndicator />
@ -53,26 +77,26 @@ export const ProfileFollowers = observer(function ProfileFollowers({
// error // error
// = // =
if (view.hasError) { if (resolveError || isError) {
return ( return (
<CenteredView> <CenteredView>
<ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> <ErrorMessage
message={cleanError(resolveError || error)}
onPressTryAgain={onRefresh}
/>
</CenteredView> </CenteredView>
) )
} }
// loaded // loaded
// = // =
const renderItem = ({item}: {item: FollowerItem}) => (
<ProfileCardWithFollowBtn key={item.did} profile={item} />
)
return ( return (
<FlatList <FlatList
data={view.followers} data={followers}
keyExtractor={item => item.did} keyExtractor={item => item.did}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={view.isRefreshing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={onRefresh}
tintColor={pal.colors.text} tintColor={pal.colors.text}
titleColor={pal.colors.text} titleColor={pal.colors.text}
@ -85,15 +109,14 @@ export const ProfileFollowers = observer(function ProfileFollowers({
// eslint-disable-next-line react/no-unstable-nested-components // eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={styles.footer}> <View style={styles.footer}>
{view.isLoading && <ActivityIndicator />} {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View> </View>
)} )}
extraData={view.isLoading}
// @ts-ignore our .web version only -prf // @ts-ignore our .web version only -prf
desktopFixedHeight desktopFixedHeight
/> />
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
footer: { footer: {

View File

@ -1,42 +1,73 @@
import React, {useEffect} from 'react' import React from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views' import {CenteredView, FlatList} from '../util/Views'
import {UserFollowsModel, FollowItem} from 'state/models/lists/user-follows'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from './ProfileCard' import {ProfileCardWithFollowBtn} from './ProfileCard'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {logger} from '#/logger' import {logger} from '#/logger'
import {cleanError} from '#/lib/strings/errors'
export const ProfileFollows = observer(function ProfileFollows({ export function ProfileFollows({name}: {name: string}) {
name,
}: {
name: string
}) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const [isPTRing, setIsPTRing] = React.useState(false)
const view = React.useMemo( const {
() => new UserFollowsModel(store, {actor: name}), data: resolvedDid,
[store, name], error: resolveError,
isFetching: isFetchingDid,
} = useResolveDidQuery(name)
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = useProfileFollowsQuery(resolvedDid?.did)
const follows = React.useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.follows)
}
}, [data])
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh follows', {error: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = async () => {
if (isFetching || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more follows', {error: err})
}
}
const renderItem = React.useCallback(
({item}: {item: ActorDefs.ProfileViewBasic}) => (
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
),
[dataUpdatedAt],
) )
useEffect(() => { if (isFetchingDid || !isFetched) {
view
.loadMore()
.catch(err => logger.error('Failed to fetch user follows', err))
}, [view])
const onRefresh = () => {
view.refresh()
}
const onEndReached = () => {
view
.loadMore()
.catch(err => logger.error('Failed to load more follows', err))
}
if (!view.hasLoaded) {
return ( return (
<CenteredView> <CenteredView>
<ActivityIndicator /> <ActivityIndicator />
@ -46,26 +77,26 @@ export const ProfileFollows = observer(function ProfileFollows({
// error // error
// = // =
if (view.hasError) { if (resolveError || isError) {
return ( return (
<CenteredView> <CenteredView>
<ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> <ErrorMessage
message={cleanError(resolveError || error)}
onPressTryAgain={onRefresh}
/>
</CenteredView> </CenteredView>
) )
} }
// loaded // loaded
// = // =
const renderItem = ({item}: {item: FollowItem}) => (
<ProfileCardWithFollowBtn key={item.did} profile={item} />
)
return ( return (
<FlatList <FlatList
data={view.follows} data={follows}
keyExtractor={item => item.did} keyExtractor={item => item.did}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={view.isRefreshing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={onRefresh}
tintColor={pal.colors.text} tintColor={pal.colors.text}
titleColor={pal.colors.text} titleColor={pal.colors.text}
@ -78,15 +109,14 @@ export const ProfileFollows = observer(function ProfileFollows({
// eslint-disable-next-line react/no-unstable-nested-components // eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={styles.footer}> <View style={styles.footer}>
{view.isLoading && <ActivityIndicator />} {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View> </View>
)} )}
extraData={view.isLoading}
// @ts-ignore our .web version only -prf // @ts-ignore our .web version only -prf
desktopFixedHeight desktopFixedHeight
/> />
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
footer: { footer: {

View File

@ -6,20 +6,16 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
Easing, Easing,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useQuery} from '@tanstack/react-query'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import {useFollowProfile} from 'lib/hooks/useFollowProfile'
import {Button} from 'view/com/util/forms/Button' import {Button} from 'view/com/util/forms/Button'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
@ -27,6 +23,13 @@ import {makeProfileLink} from 'lib/routes/links'
import {Link} from 'view/com/util/Link' import {Link} from 'view/com/util/Link'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useModerationOpts} from '#/state/queries/preferences'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {
useProfileFollowMutation,
useProfileUnfollowMutation,
} from '#/state/queries/profile'
const OUTER_PADDING = 10 const OUTER_PADDING = 10
const INNER_PADDING = 14 const INNER_PADDING = 14
@ -43,7 +46,6 @@ export function ProfileHeaderSuggestedFollows({
}) { }) {
const {track} = useAnalytics() const {track} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const animatedHeight = useSharedValue(0) const animatedHeight = useSharedValue(0)
const animatedStyles = useAnimatedStyle(() => ({ const animatedStyles = useAnimatedStyle(() => ({
opacity: animatedHeight.value / TOTAL_HEIGHT, opacity: animatedHeight.value / TOTAL_HEIGHT,
@ -66,31 +68,8 @@ export function ProfileHeaderSuggestedFollows({
} }
}, [active, animatedHeight, track]) }, [active, animatedHeight, track])
const {isLoading, data: suggestedFollows} = useQuery({ const {isLoading, data, dataUpdatedAt} = useSuggestedFollowsByActorQuery({
enabled: active, did: actorDid,
cacheTime: 0,
staleTime: 0,
queryKey: ['suggested_follows_by_actor', actorDid],
async queryFn() {
try {
const {
data: {suggestions},
success,
} = await store.agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: actorDid,
})
if (!success) {
return []
}
store.me.follows.hydrateMany(suggestions)
return suggestions
} catch (e) {
return []
}
},
}) })
return ( return (
@ -149,9 +128,13 @@ export function ProfileHeaderSuggestedFollows({
<SuggestedFollowSkeleton /> <SuggestedFollowSkeleton />
<SuggestedFollowSkeleton /> <SuggestedFollowSkeleton />
</> </>
) : suggestedFollows ? ( ) : data ? (
suggestedFollows.map(profile => ( data.suggestions.map(profile => (
<SuggestedFollow key={profile.did} profile={profile} /> <SuggestedFollow
key={profile.did}
profile={profile}
dataUpdatedAt={dataUpdatedAt}
/>
)) ))
) : ( ) : (
<View /> <View />
@ -214,29 +197,51 @@ function SuggestedFollowSkeleton() {
) )
} }
const SuggestedFollow = observer(function SuggestedFollowImpl({ function SuggestedFollow({
profile, profile: profileUnshadowed,
dataUpdatedAt,
}: { }: {
profile: AppBskyActorDefs.ProfileView profile: AppBskyActorDefs.ProfileView
dataUpdatedAt: number
}) { }) {
const {track} = useAnalytics() const {track} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const moderationOpts = useModerationOpts()
const {following, toggle} = useFollowProfile(profile) const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
const moderation = moderateProfile(profile, store.preferences.moderationOpts) const followMutation = useProfileFollowMutation()
const unfollowMutation = useProfileUnfollowMutation()
const onPress = React.useCallback(async () => { const onPressFollow = React.useCallback(async () => {
try { if (profile.viewer?.following) {
const {following: isFollowing} = await toggle() return
if (isFollowing) {
track('ProfileHeader:SuggestedFollowFollowed')
} }
try {
track('ProfileHeader:SuggestedFollowFollowed')
await followMutation.mutateAsync({did: profile.did})
} catch (e: any) { } catch (e: any) {
Toast.show('An issue occurred, please try again.') Toast.show('An issue occurred, please try again.')
} }
}, [toggle, track]) }, [followMutation, profile, track])
const onPressUnfollow = React.useCallback(async () => {
if (!profile.viewer?.following) {
return
}
try {
await unfollowMutation.mutateAsync({
did: profile.did,
followUri: profile.viewer?.following,
})
} catch (e: any) {
Toast.show('An issue occurred, please try again.')
}
}, [unfollowMutation, profile])
if (!moderationOpts) {
return null
}
const moderation = moderateProfile(profile, moderationOpts)
const following = profile.viewer?.following
return ( return (
<Link <Link
href={makeProfileLink(profile)} href={makeProfileLink(profile)}
@ -278,13 +283,13 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({
label={following ? 'Unfollow' : 'Follow'} label={following ? 'Unfollow' : 'Follow'}
type="inverted" type="inverted"
labelStyle={{textAlign: 'center'}} labelStyle={{textAlign: 'center'}}
onPress={onPress} onPress={following ? onPressUnfollow : onPressFollow}
withLoading withLoading
/> />
</View> </View>
</Link> </Link>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
suggestedFollowCardOuter: { suggestedFollowCardOuter: {