From 8e7ee4c94c2d05fffeb8ae4151f13c23ee943803 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 3 Jun 2024 09:06:32 -0700 Subject: [PATCH] add a tab for starter packs --- .../StarterPack/ProfileStarterPacks.tsx | 209 ++++++++++++++++++ .../StarterPack/StarterPackCard.tsx | 13 ++ src/view/screens/Profile.tsx | 98 ++++---- 3 files changed, 271 insertions(+), 49 deletions(-) create mode 100644 src/components/StarterPack/ProfileStarterPacks.tsx create mode 100644 src/components/StarterPack/StarterPackCard.tsx diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx new file mode 100644 index 0000000000..58034f0d7f --- /dev/null +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -0,0 +1,209 @@ +import React from 'react' +import { + findNodeHandle, + ListRenderItemInfo, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' + +import {cleanError} from '#/lib/strings/errors' +import {useTheme} from '#/lib/ThemeContext' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {hydrateFeedGenerator} from '#/state/queries/feed' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' +import {usePalette} from 'lib/hooks/usePalette' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {FeedSourceCardLoaded} from 'view/com/feeds/FeedSourceCard' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {List, ListRef} from 'view/com/util/List' +import {LoadMoreRetryBtn} from 'view/com/util/LoadMoreRetryBtn' +import {Text} from 'view/com/util/text/Text' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +interface SectionRef { + scrollToTop: () => void +} + +interface ProfileFeedgensProps { + did: string + scrollElRef: ListRef + headerOffset: number + enabled?: boolean + style?: StyleProp + testID?: string + setScrollViewTag: (tag: number | null) => void +} + +export const ProfileStarterPacks = React.forwardRef< + SectionRef, + ProfileFeedgensProps +>(function ProfileFeedgensImpl( + {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, + ref, +) { + const pal = usePalette('default') + const {_} = useLingui() + const theme = useTheme() + const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFeedgensQuery(did, opts) + 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]) + + const queryClient = useQueryClient() + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerOffset, + }) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, [scrollElRef, queryClient, headerOffset, did]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh starter packs', {message: 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 starter packs', {message: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + const renderItem = React.useCallback( + ({item, index}: ListRenderItemInfo) => { + if (item === EMPTY) { + return ( + + + You have no feeds. + + + ) + } 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, _], + ) + + React.useEffect(() => { + if (enabled && scrollElRef.current) { + const nativeTag = findNodeHandle(scrollElRef.current) + setScrollViewTag(nativeTag) + } + }, [enabled, scrollElRef, setScrollViewTag]) + + return ( + + item._reactKey || item.uri} + renderItem={renderItem} + refreshing={isPTRing} + onRefresh={onRefresh} + headerOffset={headerOffset} + contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + removeClippedSubviews={true} + // @ts-ignore our .web version only -prf + desktopFixedHeight + onEndReached={onEndReached} + /> + + ) +}) + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + }, +}) diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx new file mode 100644 index 0000000000..90cbb71d62 --- /dev/null +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {Text} from '#/components/Typography' + +export function StarterPackCard() { + return ( + + Hello! + + ) +} diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 734230c6c9..e737a8ee44 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React, {useCallback, useMemo} from 'react' import {StyleSheet} from 'react-native' import { AppBskyActorDefs, @@ -34,6 +34,7 @@ import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {ScreenHider} from '#/components/moderation/ScreenHider' +import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileLists} from '../com/lists/ProfileLists' @@ -162,6 +163,7 @@ function ProfileScreenLoaded({ const likesSectionRef = React.useRef(null) const feedsSectionRef = React.useRef(null) const listsSectionRef = React.useRef(null) + const starterPacksSectionRef = React.useRef(null) const labelsSectionRef = React.useRef(null) useSetTitle(combinedDisplayName(profile)) @@ -183,31 +185,21 @@ function ProfileScreenLoaded({ const showMediaTab = !hasLabeler const showLikesTab = isMe const showFeedsTab = isMe || (profile.associated?.feedgens || 0) > 0 + const showStarterPacksTab = isMe || true const showListsTab = hasSession && (isMe || (profile.associated?.lists || 0) > 0) - const sectionTitles = useMemo(() => { - return [ - showFiltersTab ? _(msg`Labels`) : undefined, - showListsTab && hasLabeler ? _(msg`Lists`) : undefined, - showPostsTab ? _(msg`Posts`) : undefined, - showRepliesTab ? _(msg`Replies`) : undefined, - showMediaTab ? _(msg`Media`) : undefined, - showLikesTab ? _(msg`Likes`) : undefined, - showFeedsTab ? _(msg`Feeds`) : undefined, - showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, - ].filter(Boolean) as string[] - }, [ - showPostsTab, - showRepliesTab, - showMediaTab, - showLikesTab, - showFeedsTab, - showListsTab, - showFiltersTab, - hasLabeler, - _, - ]) + const sectionTitles = [ + showFiltersTab ? _(msg`Labels`) : undefined, + showListsTab && hasLabeler ? _(msg`Lists`) : undefined, + showPostsTab ? _(msg`Posts`) : undefined, + showRepliesTab ? _(msg`Replies`) : undefined, + showMediaTab ? _(msg`Media`) : undefined, + showLikesTab ? _(msg`Likes`) : undefined, + showFeedsTab ? _(msg`Feeds`) : undefined, + showStarterPacksTab ? _(msg`Starter Packs`) : undefined, + showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, + ].filter(Boolean) as string[] let nextIndex = 0 let filtersIndex: number | null = null @@ -216,6 +208,7 @@ function ProfileScreenLoaded({ let mediaIndex: number | null = null let likesIndex: number | null = null let feedsIndex: number | null = null + let starterPacksIndex: number | null = null let listsIndex: number | null = null if (showFiltersTab) { filtersIndex = nextIndex++ @@ -235,11 +228,14 @@ function ProfileScreenLoaded({ if (showFeedsTab) { feedsIndex = nextIndex++ } + if (showStarterPacksTab) { + starterPacksIndex = nextIndex++ + } if (showListsTab) { listsIndex = nextIndex++ } - const scrollSectionToTop = React.useCallback( + const scrollSectionToTop = useCallback( (index: number) => { if (index === filtersIndex) { labelsSectionRef.current?.scrollToTop() @@ -253,18 +249,21 @@ function ProfileScreenLoaded({ likesSectionRef.current?.scrollToTop() } else if (index === feedsIndex) { feedsSectionRef.current?.scrollToTop() + } else if (index === starterPacksIndex) { + starterPacksSectionRef.current?.scrollToTop() } else if (index === listsIndex) { listsSectionRef.current?.scrollToTop() } }, [ + feedsIndex, filtersIndex, + likesIndex, + listsIndex, + mediaIndex, postsIndex, repliesIndex, - mediaIndex, - likesIndex, - feedsIndex, - listsIndex, + starterPacksIndex, ], ) @@ -280,6 +279,7 @@ function ProfileScreenLoaded({ useFocusEffect( React.useCallback(() => { + console.log('run') setDrawerSwipeDisabled(currentPage > 0) return () => { setDrawerSwipeDisabled(false) @@ -290,7 +290,7 @@ function ProfileScreenLoaded({ // events // = - const onPressCompose = React.useCallback(() => { + const onPressCompose = () => { track('ProfileScreen:PressCompose') const mention = profile.handle === currentAccount?.handle || @@ -298,23 +298,20 @@ function ProfileScreenLoaded({ ? undefined : profile.handle openComposer({mention}) - }, [openComposer, currentAccount, track, profile]) + } - const onPageSelected = React.useCallback((i: number) => { + const onPageSelected = (i: number) => { setCurrentPage(i) - }, []) + } - const onCurrentPageSelected = React.useCallback( - (index: number) => { - scrollSectionToTop(index) - }, - [scrollSectionToTop], - ) + const onCurrentPageSelected = (index: number) => { + scrollSectionToTop(index) + } // rendering // = - const renderHeader = React.useCallback(() => { + const renderHeader = () => { return ( ) - }, [ - scrollViewTag, - profile, - labelerInfo, - hasDescription, - descriptionRT, - moderationOpts, - hideBackButton, - showPlaceholder, - ]) + } return ( ) : null} + {showStarterPacksTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + + ) + : null} {showListsTab && !profile.associated?.labeler ? ({headerHeight, isFocused, scrollElRef}) => (