diff options
Diffstat (limited to 'src/view/screens/Feeds.tsx')
-rw-r--r-- | src/view/screens/Feeds.tsx | 695 |
1 files changed, 481 insertions, 214 deletions
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 169660a8f..f319fbc39 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,15 +1,12 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View, RefreshControl} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {FAB} from 'view/com/util/fab/FAB' import {Link} from 'view/com/util/Link' import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ComposeIcon2, CogIcon} from 'lib/icons' import {s} from 'lib/styles' @@ -22,255 +19,525 @@ import { import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' -import {MyFeedsItem} from 'state/models/ui/my-feeds' -import {FeedSourceModel} from 'state/models/content/feed-source' import {FlatList} from 'view/com/util/Views' import {useFocusEffect} from '@react-navigation/native' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {usePreferencesQuery} from '#/state/queries/preferences' +import { + useFeedSourceInfoQuery, + useGetPopularFeedsQuery, + useSearchPopularFeedsMutation, +} from '#/state/queries/feed' +import {cleanError} from 'lib/strings/errors' +import {useComposerControls} from '#/state/shell/composer' +import {useSession} from '#/state/session' type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> -export const FeedsScreen = withAuthRequired( - observer<Props>(function FeedsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const myFeeds = store.me.myFeeds - const [query, setQuery] = React.useState<string>('') - const debouncedSearchFeeds = React.useMemo( - () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms - [myFeeds], - ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - myFeeds.setup() +type FlatlistSlice = + | { + type: 'error' + key: string + error: string + } + | { + type: 'savedFeedsHeader' + key: string + } + | { + type: 'savedFeedsLoading' + key: string + // pendingItems: number, + } + | { + type: 'savedFeedNoResults' + key: string + } + | { + type: 'savedFeed' + key: string + feedUri: string + } + | { + type: 'savedFeedsLoadMore' + key: string + } + | { + type: 'popularFeedsHeader' + key: string + } + | { + type: 'popularFeedsLoading' + key: string + } + | { + type: 'popularFeedsNoResults' + key: string + } + | { + type: 'popularFeed' + key: string + feedUri: string + } + | { + type: 'popularFeedsLoadingMore' + key: string + } - const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh()) - return () => { - softResetSub.remove() - } - }, [store, myFeeds, setMinimalShellMode]), +export function FeedsScreen(_props: Props) { + const pal = usePalette('default') + const {openComposer} = useComposerControls() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const [query, setQuery] = React.useState('') + const [isPTR, setIsPTR] = React.useState(false) + const { + data: preferences, + isLoading: isPreferencesLoading, + error: preferencesError, + } = usePreferencesQuery() + const { + data: popularFeeds, + isFetching: isPopularFeedsFetching, + error: popularFeedsError, + refetch: refetchPopularFeeds, + fetchNextPage: fetchNextPopularFeedsPage, + isFetchingNextPage: isPopularFeedsFetchingNextPage, + hasNextPage: hasNextPopularFeedsPage, + } = useGetPopularFeedsQuery() + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const { + data: searchResults, + mutate: search, + reset: resetSearch, + isPending: isSearchPending, + error: searchError, + } = useSearchPopularFeedsMutation() + const {hasSession} = useSession() + + /** + * A search query is present. We may not have search results yet. + */ + const isUserSearching = query.length > 1 + const debouncedSearch = React.useMemo( + () => debounce(q => search(q), 500), // debounce for 500ms + [search], + ) + const onPressCompose = React.useCallback(() => { + openComposer({}) + }, [openComposer]) + const onChangeQuery = React.useCallback( + (text: string) => { + setQuery(text) + if (text.length > 1) { + debouncedSearch(text) + } else { + refetchPopularFeeds() + resetSearch() + } + }, + [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch], + ) + const onPressCancelSearch = React.useCallback(() => { + setQuery('') + refetchPopularFeeds() + resetSearch() + }, [refetchPopularFeeds, setQuery, resetSearch]) + const onSubmitQuery = React.useCallback(() => { + debouncedSearch(query) + }, [query, debouncedSearch]) + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetchPopularFeeds() + setIsPTR(false) + }, [setIsPTR, refetchPopularFeeds]) + const onEndReached = React.useCallback(() => { + if ( + isPopularFeedsFetching || + isUserSearching || + !hasNextPopularFeedsPage || + popularFeedsError ) - React.useEffect(() => { - // watch for changes to saved/pinned feeds - return myFeeds.registerListeners() - }, [myFeeds]) + return + fetchNextPopularFeedsPage() + }, [ + isPopularFeedsFetching, + isUserSearching, + popularFeedsError, + hasNextPopularFeedsPage, + fetchNextPopularFeedsPage, + ]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const items = React.useMemo(() => { + let slices: FlatlistSlice[] = [] - const onPressCompose = React.useCallback(() => { - store.shell.openComposer({}) - }, [store]) - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 1) { - debouncedSearchFeeds(text) + if (hasSession) { + slices.push({ + key: 'savedFeedsHeader', + type: 'savedFeedsHeader', + }) + + if (preferencesError) { + slices.push({ + key: 'savedFeedsError', + type: 'error', + error: cleanError(preferencesError.toString()), + }) + } else { + if (isPreferencesLoading || !preferences?.feeds?.saved) { + slices.push({ + key: 'savedFeedsLoading', + type: 'savedFeedsLoading', + // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, + }) } else { - myFeeds.discovery.refresh() - } - }, - [debouncedSearchFeeds, myFeeds.discovery], - ) - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - myFeeds.discovery.refresh() - }, [myFeeds]) - const onSubmitQuery = React.useCallback(() => { - debouncedSearchFeeds(query) - debouncedSearchFeeds.flush() - }, [debouncedSearchFeeds, query]) + if (preferences?.feeds?.saved.length === 0) { + slices.push({ + key: 'savedFeedNoResults', + type: 'savedFeedNoResults', + }) + } else { + const {saved, pinned} = preferences.feeds - const renderHeaderBtn = React.useCallback(() => { - return ( - <Link - href="/settings/saved-feeds" - hitSlop={10} - accessibilityRole="button" - accessibilityLabel="Edit Saved Feeds" - accessibilityHint="Opens screen to edit Saved Feeds"> - <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> - </Link> - ) - }, [pal]) + slices = slices.concat( + pinned.map(uri => ({ + key: `savedFeed:${uri}`, + type: 'savedFeed', + feedUri: uri, + })), + ) - const onRefresh = React.useCallback(() => { - myFeeds.refresh() - }, [myFeeds]) + slices = slices.concat( + saved + .filter(uri => !pinned.includes(uri)) + .map(uri => ({ + key: `savedFeed:${uri}`, + type: 'savedFeed', + feedUri: uri, + })), + ) + } + } + } + } - const renderItem = React.useCallback( - ({item}: {item: MyFeedsItem}) => { - if (item.type === 'discover-feeds-loading') { - return <FeedFeedLoadingPlaceholder /> - } else if (item.type === 'spinner') { - return ( - <View style={s.p10}> - <ActivityIndicator /> - </View> - ) - } else if (item.type === 'error') { - return <ErrorMessage message={item.error} /> - } else if (item.type === 'saved-feeds-header') { - if (!isMobile) { - return ( - <View - style={[ - pal.view, - styles.header, - pal.border, - { - borderBottomWidth: 1, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - My Feeds - </Text> - <Link - href="/settings/saved-feeds" - accessibilityLabel="Edit My Feeds" - accessibilityHint=""> - <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> - </Link> - </View> + slices.push({ + key: 'popularFeedsHeader', + type: 'popularFeedsHeader', + }) + + if (popularFeedsError || searchError) { + slices.push({ + key: 'popularFeedsError', + type: 'error', + error: cleanError( + popularFeedsError?.toString() ?? searchError?.toString() ?? '', + ), + }) + } else { + if (isUserSearching) { + if (isSearchPending || !searchResults) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if (!searchResults || searchResults?.length === 0) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + slices = slices.concat( + searchResults.map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), ) } - return <View /> - } else if (item.type === 'saved-feeds-loading') { - return ( - <> - {Array.from(Array(item.numItems)).map((_, i) => ( - <SavedFeedLoadingPlaceholder key={`placeholder-${i}`} /> - ))} - </> - ) - } else if (item.type === 'saved-feed') { - return <SavedFeed feed={item.feed} /> - } else if (item.type === 'discover-feeds-header') { - return ( - <> - <View - style={[ - pal.view, - styles.header, - { - marginTop: 16, - paddingLeft: isMobile ? 12 : undefined, - paddingRight: 10, - paddingBottom: isMobile ? 6 : undefined, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - Discover new feeds - </Text> - {!isMobile && ( - <SearchInput - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - style={{flex: 1, maxWidth: 250}} - /> - )} - </View> - {isMobile && ( - <View style={{paddingHorizontal: 8, paddingBottom: 10}}> - <SearchInput - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - /> - </View> - )} - </> - ) - } else if (item.type === 'discover-feed') { - return ( - <FeedSourceCard - item={item.feed} - showSaveBtn - showDescription - showLikes - /> - ) - } else if (item.type === 'discover-feeds-no-results') { + } + } else { + if (isPopularFeedsFetching && !popularFeeds?.pages) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if ( + !popularFeeds?.pages || + popularFeeds?.pages[0]?.feeds?.length === 0 + ) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + for (const page of popularFeeds.pages || []) { + slices = slices.concat( + page.feeds + .filter(feed => !preferences?.feeds?.saved.includes(feed.uri)) + .map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), + ) + } + + if (isPopularFeedsFetchingNextPage) { + slices.push({ + key: 'popularFeedsLoadingMore', + type: 'popularFeedsLoadingMore', + }) + } + } + } + } + } + + return slices + }, [ + hasSession, + preferences, + isPreferencesLoading, + preferencesError, + popularFeeds, + isPopularFeedsFetching, + popularFeedsError, + isPopularFeedsFetchingNextPage, + searchResults, + isSearchPending, + searchError, + isUserSearching, + ]) + + const renderHeaderBtn = React.useCallback(() => { + return ( + <Link + href="/settings/saved-feeds" + hitSlop={10} + accessibilityRole="button" + accessibilityLabel={_(msg`Edit Saved Feeds`)} + accessibilityHint="Opens screen to edit Saved Feeds"> + <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> + </Link> + ) + }, [pal, _]) + + const renderItem = React.useCallback( + ({item}: {item: FlatlistSlice}) => { + if (item.type === 'error') { + return <ErrorMessage message={item.error} /> + } else if ( + item.type === 'popularFeedsLoadingMore' || + item.type === 'savedFeedsLoading' + ) { + return ( + <View style={s.p10}> + <ActivityIndicator /> + </View> + ) + } else if (item.type === 'savedFeedsHeader') { + if (!isMobile) { return ( <View - style={{ - paddingHorizontal: 16, - paddingTop: 10, - paddingBottom: '150%', - }}> - <Text type="lg" style={pal.textLight}> - No results found for "{query}" + style={[ + pal.view, + styles.header, + pal.border, + { + borderBottomWidth: 1, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>My Feeds</Trans> </Text> + <Link + href="/settings/saved-feeds" + accessibilityLabel={_(msg`Edit My Feeds`)} + accessibilityHint=""> + <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> + </Link> </View> ) } - return null - }, - [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery], - ) + return <View /> + } else if (item.type === 'savedFeedNoResults') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + }}> + <Text type="lg" style={pal.textLight}> + <Trans>You don't have any saved feeds!</Trans> + </Text> + </View> + ) + } else if (item.type === 'savedFeed') { + return <SavedFeed feedUri={item.feedUri} /> + } else if (item.type === 'popularFeedsHeader') { + return ( + <> + <View + style={[ + pal.view, + styles.header, + { + // This is first in the flatlist without a session -esb + marginTop: hasSession ? 16 : 0, + paddingLeft: isMobile ? 12 : undefined, + paddingRight: 10, + paddingBottom: isMobile ? 6 : undefined, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>Discover new feeds</Trans> + </Text> - return ( - <View style={[pal.view, styles.container]}> - {isMobile && ( - <ViewHeader - title="Feeds" - canGoBack={false} - renderButton={renderHeaderBtn} - showBorder + {!isMobile && ( + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + style={{flex: 1, maxWidth: 250}} + /> + )} + </View> + + {isMobile && ( + <View style={{paddingHorizontal: 8, paddingBottom: 10}}> + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + /> + </View> + )} + </> + ) + } else if (item.type === 'popularFeedsLoading') { + return <FeedFeedLoadingPlaceholder /> + } else if (item.type === 'popularFeed') { + return ( + <FeedSourceCard + feedUri={item.feedUri} + showSaveBtn={hasSession} + showDescription + showLikes + pinOnSave /> - )} + ) + } else if (item.type === 'popularFeedsNoResults') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: '150%', + }}> + <Text type="lg" style={pal.textLight}> + <Trans>No results found for "{query}"</Trans> + </Text> + </View> + ) + } + return null + }, + [ + _, + hasSession, + isMobile, + pal, + query, + onChangeQuery, + onPressCancelSearch, + onSubmitQuery, + ], + ) - <FlatList - style={[!isTabletOrDesktop && s.flex1, styles.list]} - data={myFeeds.items} - keyExtractor={item => item._reactKey} - contentContainerStyle={styles.contentContainer} - refreshControl={ - <RefreshControl - refreshing={myFeeds.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={10} - onEndReached={() => myFeeds.loadMore()} - extraData={myFeeds.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight + return ( + <View style={[pal.view, styles.container]}> + {isMobile && ( + <ViewHeader + title={_(msg`Feeds`)} + canGoBack={false} + renderButton={renderHeaderBtn} + showBorder /> + )} + + {preferences ? <View /> : <ActivityIndicator />} + + <FlatList + style={[!isTabletOrDesktop && s.flex1, styles.list]} + data={items} + keyExtractor={item => item.key} + contentContainerStyle={styles.contentContainer} + renderItem={renderItem} + refreshControl={ + <RefreshControl + refreshing={isPTR} + onRefresh={isUserSearching ? undefined : onPullToRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + initialNumToRender={10} + onEndReached={onEndReached} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + + {hasSession && ( <FAB testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> - </View> - ) - }), -) + )} + </View> + ) +} -function SavedFeed({feed}: {feed: FeedSourceModel}) { +function SavedFeed({feedUri}: {feedUri: string}) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!info) + return ( + <SavedFeedLoadingPlaceholder + key={`savedFeedLoadingPlaceholder:${feedUri}`} + /> + ) + return ( <Link - testID={`saved-feed-${feed.displayName}`} - href={feed.href} + testID={`saved-feed-${info.displayName}`} + href={info.route.href} style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} hoverStyle={pal.viewLight} - accessibilityLabel={feed.displayName} + accessibilityLabel={info.displayName} accessibilityHint="" asAnchor anchorNoUnderline> - {feed.error ? ( + {error ? ( <View style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> <FontAwesomeIcon @@ -279,17 +546,17 @@ function SavedFeed({feed}: {feed: FeedSourceModel}) { /> </View> ) : ( - <UserAvatar type="algo" size={28} avatar={feed.avatar} /> + <UserAvatar type="algo" size={28} avatar={info.avatar} /> )} <View style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> <Text type="lg-medium" style={pal.text} numberOfLines={1}> - {feed.displayName} + {info.displayName} </Text> - {feed.error ? ( + {error ? ( <View style={[styles.offlineSlug, pal.borderDark]}> <Text type="xs" style={pal.textLight}> - Feed offline + <Trans>Feed offline</Trans> </Text> </View> ) : null} |