diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-11-13 14:46:19 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-13 14:46:19 -0800 |
commit | 9fca7b3af6114d28e13f475395c8911c55b33cb1 (patch) | |
tree | 1cf22e7dfe3d4778d8eaf62b66c6d8ba3ef8c227 /src/view/com/feeds | |
parent | b04748e703cede419a027f6bbb3c8180ed10eb1f (diff) | |
download | voidsky-9fca7b3af6114d28e13f475395c8911c55b33cb1.tar.zst |
Add feedgens tab to profile (#1889)
Diffstat (limited to 'src/view/com/feeds')
-rw-r--r-- | src/view/com/feeds/FeedSourceCard.tsx | 93 | ||||
-rw-r--r-- | src/view/com/feeds/ProfileFeedgens.tsx | 199 |
2 files changed, 261 insertions, 31 deletions
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index aaafd1959..4e461e4f6 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, @@ -35,30 +35,61 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ showDescription?: boolean showLikes?: boolean }) { + const {data: preferences} = usePreferencesQuery() + const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!feed || !preferences) return null + + return ( + <FeedSourceCardLoaded + feed={feed} + preferences={preferences} + style={style} + showSaveBtn={showSaveBtn} + showDescription={showDescription} + showLikes={showLikes} + /> + ) +} + +export function FeedSourceCardLoaded({ + feed, + preferences, + style, + showSaveBtn = false, + showDescription = false, + showLikes = false, +}: { + feed: FeedSourceInfo + preferences: UsePreferencesQueryResponse + style?: StyleProp<ViewStyle> + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean +}) { const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() 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 ( <Pressable - testID={`feed-${info.displayName}`} + testID={`feed-${feed.displayName}`} accessibilityRole="button" style={[styles.container, pal.border, style]} onPress={() => { - 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}> <View style={[styles.headerContainer]}> <View style={[s.mr10]}> - <UserAvatar type="algo" size={36} avatar={info.avatar} /> + <UserAvatar type="algo" size={36} avatar={feed.avatar} /> </View> <View style={[styles.headerTextContainer]}> <Text style={[pal.text, s.bold]} numberOfLines={3}> - {info.displayName} + {feed.displayName} </Text> <Text style={[pal.textLight]} numberOfLines={3}> - {info.type === 'feed' ? 'Feed' : 'List'} by{' '} - {sanitizeHandle(info.creatorHandle, '@')} + {feed.type === 'feed' ? 'Feed' : 'List'} by{' '} + {sanitizeHandle(feed.creatorHandle, '@')} </Text> </View> - {showSaveBtn && info.type === 'feed' && ( + {showSaveBtn && feed.type === 'feed' && ( <View> <Pressable disabled={isSavePending || isRemovePending} @@ -143,23 +174,23 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ )} </View> - {showDescription && info.description ? ( + {showDescription && feed.description ? ( <RichText style={[pal.textLight, styles.description]} - richText={info.description} + richText={feed.description} numberOfLines={3} /> ) : null} - {showLikes && info.type === 'feed' ? ( + {showLikes && feed.type === 'feed' ? ( <Text type="sm-medium" style={[pal.text, pal.textLight]}> - Liked by {info.likeCount || 0}{' '} - {pluralize(info.likeCount || 0, 'user')} + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} </Text> ) : null} </Pressable> ) -}) +} 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 000000000..2cc688c50 --- /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<FlatList<any> | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + style?: StyleProp<ViewStyle> + 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 ( + <View + testID="listsEmpty" + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> + <Text style={pal.textLight}> + <Trans>You have no lists.</Trans> + </Text> + </View> + ) + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching your lists. Tap here to try again." + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING) { + return ( + <View style={{padding: 20}}> + <ActivityIndicator /> + </View> + ) + } + if (preferences) { + return ( + <FeedSourceCardLoaded + feed={item} + preferences={preferences} + style={styles.item} + /> + ) + } + return null + }, + [error, refetch, onPressRetryLoadMore, pal, preferences], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + <View testID={testID} style={style}> + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + keyExtractor={(item: any) => item._reactKey} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + 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} + /> + </View> + ) +} + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + paddingVertical: 4, + }, +}) |