diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts index 54b19c89a1..6ba665086b 100644 --- a/src/state/queries/profile-extra-info.ts +++ b/src/state/queries/profile-extra-info.ts @@ -24,7 +24,7 @@ export function useProfileExtraInfoQuery(did: string) { ]) return { hasLists: listsRes.data.lists.length > 0, - hasFeeds: feedsRes.data.feeds.length > 0, + hasFeedgens: feedsRes.data.feeds.length > 0, } }, }) diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts new file mode 100644 index 0000000000..652a123d94 --- /dev/null +++ b/src/state/queries/profile-feedgens.ts @@ -0,0 +1,31 @@ +import {AppBskyFeedGetActorFeeds} 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-feedgens', did] + +export function useProfileFeedgensQuery(did: string) { + const {agent} = useSession() + return useInfiniteQuery< + AppBskyFeedGetActorFeeds.OutputSchema, + Error, + InfiniteData, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(did), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await agent.app.bsky.feed.getActorFeeds({ + actor: did, + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index aaafd19592..4e461e4f63 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -6,7 +6,6 @@ import {RichText} from '../util/text/RichText' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' -import {observer} from 'mobx-react-lite' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {pluralize} from 'lib/strings/helpers' @@ -16,13 +15,14 @@ import {sanitizeHandle} from 'lib/strings/handles' import {logger} from '#/logger' import {useModalControls} from '#/state/modals' import { + UsePreferencesQueryResponse, usePreferencesQuery, useSaveFeedMutation, useRemoveFeedMutation, } from '#/state/queries/preferences' -import {useFeedSourceInfoQuery} from '#/state/queries/feed' +import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' -export const FeedSourceCard = observer(function FeedSourceCardImpl({ +export function FeedSourceCard({ feedUri, style, showSaveBtn = false, @@ -34,31 +34,62 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean +}) { + const {data: preferences} = usePreferencesQuery() + const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!feed || !preferences) return null + + return ( + + ) +} + +export function FeedSourceCardLoaded({ + feed, + preferences, + style, + showSaveBtn = false, + showDescription = false, + showLikes = false, +}: { + feed: FeedSourceInfo + preferences: UsePreferencesQueryResponse + style?: StyleProp + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean }) { const pal = usePalette('default') const navigation = useNavigation() const {openModal} = useModalControls() - const {data: preferences} = usePreferencesQuery() - const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) + const {isPending: isSavePending, mutateAsync: saveFeed} = useSaveFeedMutation() const {isPending: isRemovePending, mutateAsync: removeFeed} = useRemoveFeedMutation() - const isSaved = Boolean(preferences?.feeds?.saved?.includes(feedUri)) + const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed.uri)) const onToggleSaved = React.useCallback(async () => { // Only feeds can be un/saved, lists are handled elsewhere - if (info?.type !== 'feed') return + if (feed?.type !== 'feed') return if (isSaved) { openModal({ name: 'confirm', title: 'Remove from my feeds', - message: `Remove ${info?.displayName} from my feeds?`, + message: `Remove ${feed?.displayName} from my feeds?`, onPressConfirm: async () => { try { - await removeFeed({uri: feedUri}) + await removeFeed({uri: feed.uri}) // await item.unsave() Toast.show('Removed from my feeds') } catch (e) { @@ -69,51 +100,51 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ }) } else { try { - await saveFeed({uri: feedUri}) + await saveFeed({uri: feed.uri}) Toast.show('Added to my feeds') } catch (e) { Toast.show('There was an issue contacting your server') logger.error('Failed to save feed', {error: e}) } } - }, [isSaved, openModal, info, feedUri, removeFeed, saveFeed]) + }, [isSaved, openModal, feed, removeFeed, saveFeed]) - if (!info || !preferences) return null + if (!feed || !preferences) return null return ( { - if (info.type === 'feed') { + if (feed.type === 'feed') { navigation.push('ProfileFeed', { - name: info.creatorDid, - rkey: new AtUri(info.uri).rkey, + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, }) - } else if (info.type === 'list') { + } else if (feed.type === 'list') { navigation.push('ProfileList', { - name: info.creatorDid, - rkey: new AtUri(info.uri).rkey, + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, }) } }} - key={info.uri}> + key={feed.uri}> - + - {info.displayName} + {feed.displayName} - {info.type === 'feed' ? 'Feed' : 'List'} by{' '} - {sanitizeHandle(info.creatorHandle, '@')} + {feed.type === 'feed' ? 'Feed' : 'List'} by{' '} + {sanitizeHandle(feed.creatorHandle, '@')} - {showSaveBtn && info.type === 'feed' && ( + {showSaveBtn && feed.type === 'feed' && ( - {showDescription && info.description ? ( + {showDescription && feed.description ? ( ) : null} - {showLikes && info.type === 'feed' ? ( + {showLikes && feed.type === 'feed' ? ( - Liked by {info.likeCount || 0}{' '} - {pluralize(info.likeCount || 0, 'user')} + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} ) : null} ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx new file mode 100644 index 0000000000..2cc688c50b --- /dev/null +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -0,0 +1,199 @@ +import React, {MutableRefObject} from 'react' +import { + ActivityIndicator, + Dimensions, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {FlatList} from '../util/Views' +import {FeedSourceCardLoaded} from './FeedSourceCard' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' +import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useTheme} from '#/lib/ThemeContext' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {hydrateFeedGenerator} from '#/state/queries/feed' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +export function ProfileFeedgens({ + did, + scrollElRef, + onScroll, + scrollEventThrottle, + headerOffset, + style, + testID, +}: { + did: string + scrollElRef?: MutableRefObject | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + style?: StyleProp + testID?: string +}) { + const pal = usePalette('default') + const theme = useTheme() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFeedgensQuery(did) + const isEmpty = !isFetching && !data?.pages[0]?.feeds.length + const {data: preferences} = usePreferencesQuery() + + const items = React.useMemo(() => { + let items: any[] = [] + if (isError && isEmpty) { + items = items.concat([ERROR_ITEM]) + } + if (!isFetched && isFetching) { + items = items.concat([LOADING]) + } else if (isEmpty) { + items = items.concat([EMPTY]) + } else if (data?.pages) { + for (const page of data?.pages) { + items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed))) + } + } + if (isError && !isEmpty) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + return items + }, [isError, isEmpty, isFetched, isFetching, data]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh feeds', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more feeds', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + // rendering + // = + + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY) { + return ( + + + You have no lists. + + + ) + } else if (item === ERROR_ITEM) { + return ( + + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + + ) + } else if (item === LOADING) { + return ( + + + + ) + } + if (preferences) { + return ( + + ) + } + return null + }, + [error, refetch, onPressRetryLoadMore, pal, preferences], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + + item._reactKey} + renderItem={renderItemInner} + refreshControl={ + + } + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} + style={{paddingTop: headerOffset}} + onScroll={onScroll != null ? scrollHandler : undefined} + scrollEventThrottle={scrollEventThrottle} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + onEndReached={onEndReached} + /> + + ) +} + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + paddingVertical: 4, + }, +}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 065a03f118..669afb3719 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -11,6 +11,7 @@ import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' import {Feed} from 'view/com/posts/Feed' import {ProfileLists} from '../com/lists/ProfileLists' +import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {useStores} from 'state/index' import {ProfileHeader} from '../com/profile/ProfileHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' @@ -140,7 +141,7 @@ function ProfileScreenLoaded({ const isMe = profile.did === currentAccount?.did const showLikesTab = isMe - const showFeedsTab = isMe || extraInfoQuery.data?.hasFeeds + const showFeedsTab = isMe || extraInfoQuery.data?.hasFeedgens const showListsTab = isMe || extraInfoQuery.data?.hasLists const sectionTitles = useMemo(() => { return [ @@ -267,7 +268,7 @@ function ProfileScreenLoaded({ : null} {showFeedsTab ? ({onScroll, headerHeight, scrollElRef}) => ( -