import React from 'react' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {type AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' import debounce from 'lodash.debounce' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {ComposeIcon2} from '#/lib/icons' import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {s} from '#/lib/styles' import {isNative, isWeb} from '#/platform/detection' import { type SavedFeedItem, useGetPopularFeedsQuery, useSavedFeeds, useSearchPopularFeedsMutation, } from '#/state/queries/feed' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {FAB} from '#/view/com/util/fab/FAB' import {List, type ListMethods} from '#/view/com/util/List' import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {Text} from '#/view/com/util/text/Text' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {atoms as a, useTheme} from '#/alf' import {ButtonIcon} from '#/components/Button' import {Divider} from '#/components/Divider' import * as FeedCard from '#/components/FeedCard' import {SearchInput} from '#/components/forms/SearchInput' import {IconCircle} from '#/components/IconCircle' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import * as ListCard from '#/components/ListCard' type Props = NativeStackScreenProps type FlatlistSlice = | { type: 'error' key: string error: string } | { type: 'savedFeedsHeader' key: string } | { type: 'savedFeedPlaceholder' key: string } | { type: 'savedFeedNoResults' key: string } | { type: 'savedFeed' key: string savedFeed: SavedFeedItem } | { type: 'savedFeedsLoadMore' key: string } | { type: 'popularFeedsHeader' key: string } | { type: 'popularFeedsLoading' key: string } | { type: 'popularFeedsNoResults' key: string } | { type: 'popularFeed' key: string feedUri: string feed: AppBskyFeedDefs.GeneratorView } | { type: 'popularFeedsLoadingMore' key: string } | { type: 'noFollowingFeed' key: string } export function FeedsScreen(_props: Props) { const pal = usePalette('default') const {openComposer} = useOpenComposer() const {isMobile} = useWebMediaQueries() const [query, setQuery] = React.useState('') const [isPTR, setIsPTR] = React.useState(false) const { data: savedFeeds, isPlaceholderData: isSavedFeedsPlaceholder, error: savedFeedsError, refetch: refetchSavedFeeds, } = useSavedFeeds() 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() const listRef = React.useRef(null) /** * 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 Promise.all([ refetchSavedFeeds().catch(_e => undefined), refetchPopularFeeds().catch(_e => undefined), ]) setIsPTR(false) }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds]) const onEndReached = React.useCallback(() => { if ( isPopularFeedsFetching || isUserSearching || !hasNextPopularFeedsPage || popularFeedsError ) return fetchNextPopularFeedsPage() }, [ isPopularFeedsFetching, isUserSearching, popularFeedsError, hasNextPopularFeedsPage, fetchNextPopularFeedsPage, ]) useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) }, [setMinimalShellMode]), ) const items = React.useMemo(() => { let slices: FlatlistSlice[] = [] const hasActualSavedCount = !isSavedFeedsPlaceholder || (isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0) const canShowDiscoverSection = !hasSession || (hasSession && hasActualSavedCount) if (hasSession) { slices.push({ key: 'savedFeedsHeader', type: 'savedFeedsHeader', }) if (savedFeedsError) { slices.push({ key: 'savedFeedsError', type: 'error', error: cleanError(savedFeedsError.toString()), }) } else { if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) { /* * Initial render in placeholder state is 0 on a cold page load, * because preferences haven't loaded yet. * * In practice, `savedFeeds` is always defined, but we check for TS * and for safety. * * In both cases, we show 4 as the the loading state. */ const min = 8 const count = savedFeeds ? savedFeeds.count === 0 ? min : savedFeeds.count : min Array(count) .fill(0) .forEach((_, i) => { slices.push({ key: 'savedFeedPlaceholder' + i, type: 'savedFeedPlaceholder', }) }) } else { if (savedFeeds?.feeds?.length) { const noFollowingFeed = savedFeeds.feeds.every( f => f.type !== 'timeline', ) slices = slices.concat( savedFeeds.feeds .filter(s => { return s.config.pinned }) .map(s => ({ key: `savedFeed:${s.view?.uri}:${s.config.id}`, type: 'savedFeed', savedFeed: s, })), ) slices = slices.concat( savedFeeds.feeds .filter(s => { return !s.config.pinned }) .map(s => ({ key: `savedFeed:${s.view?.uri}:${s.config.id}`, type: 'savedFeed', savedFeed: s, })), ) if (noFollowingFeed) { slices.push({ key: 'noFollowingFeed', type: 'noFollowingFeed', }) } } else { slices.push({ key: 'savedFeedNoResults', type: 'savedFeedNoResults', }) } } } } if (!hasSession || (hasSession && canShowDiscoverSection)) { 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, feed, })), ) } } } else { if (isPopularFeedsFetching && !popularFeeds?.pages) { slices.push({ key: 'popularFeedsLoading', type: 'popularFeedsLoading', }) } else { if (!popularFeeds?.pages) { slices.push({ key: 'popularFeedsNoResults', type: 'popularFeedsNoResults', }) } else { for (const page of popularFeeds.pages || []) { slices = slices.concat( page.feeds.map(feed => ({ key: `popularFeed:${feed.uri}`, type: 'popularFeed', feedUri: feed.uri, feed, })), ) } if (isPopularFeedsFetchingNextPage) { slices.push({ key: 'popularFeedsLoadingMore', type: 'popularFeedsLoadingMore', }) } } } } } } return slices }, [ hasSession, savedFeeds, isSavedFeedsPlaceholder, savedFeedsError, popularFeeds, isPopularFeedsFetching, popularFeedsError, isPopularFeedsFetchingNextPage, searchResults, isSearchPending, searchError, isUserSearching, ]) const searchBarIndex = items.findIndex( item => item.type === 'popularFeedsHeader', ) const onChangeSearchFocus = React.useCallback( (focus: boolean) => { if (focus && searchBarIndex > -1) { if (isNative) { // scrollToIndex scrolls the exact right amount, so use if available listRef.current?.scrollToIndex({ index: searchBarIndex, animated: true, }) } else { // web implementation only supports scrollToOffset // thus, we calculate the offset based on the index // pixel values are estimates, I wasn't able to get it pixel perfect :( const headerHeight = isMobile ? 43 : 53 const feedItemHeight = isMobile ? 49 : 58 listRef.current?.scrollToOffset({ offset: searchBarIndex * feedItemHeight - headerHeight, animated: true, }) } } }, [searchBarIndex, isMobile], ) const renderItem = React.useCallback( ({item}: {item: FlatlistSlice}) => { if (item.type === 'error') { return } else if (item.type === 'popularFeedsLoadingMore') { return ( ) } else if (item.type === 'savedFeedsHeader') { return } else if (item.type === 'savedFeedNoResults') { return ( ) } else if (item.type === 'savedFeedPlaceholder') { return } else if (item.type === 'savedFeed') { return } else if (item.type === 'popularFeedsHeader') { return ( <> onChangeSearchFocus(true)} onBlur={() => onChangeSearchFocus(false)} /> ) } else if (item.type === 'popularFeedsLoading') { return } else if (item.type === 'popularFeed') { return ( ) } else if (item.type === 'popularFeedsNoResults') { return ( No results found for "{query}" ) } else if (item.type === 'noFollowingFeed') { return ( ) } return null }, [ _, pal.border, pal.textLight, query, onChangeQuery, onPressCancelSearch, onSubmitQuery, onChangeSearchFocus, ], ) return ( Feeds item.key} contentContainerStyle={styles.contentContainer} renderItem={renderItem} refreshing={isPTR} onRefresh={isUserSearching ? undefined : onPullToRefresh} initialNumToRender={10} onEndReached={onEndReached} desktopFixedHeight keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" sideBorders={false} /> {hasSession && ( } accessibilityRole="button" accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> )} ) } function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) { return savedFeed.type === 'timeline' ? ( ) : ( ) } function FollowingFeed() { const t = useTheme() const {_} = useLingui() return ( ) } function SavedFeed({ savedFeed, }: { savedFeed: SavedFeedItem & {type: 'feed' | 'list'} }) { const t = useTheme() const commonStyle = [ a.w_full, a.flex_1, a.px_lg, a.py_md, a.border_b, t.atoms.border_contrast_low, ] return savedFeed.type === 'feed' ? ( {({hovered, pressed}) => ( )} ) : ( {({hovered, pressed}) => ( )} ) } function SavedFeedPlaceholder() { const t = useTheme() return ( ) } function FeedsSavedHeader() { const t = useTheme() return ( My Feeds All the feeds you've saved, right in one place. ) } function FeedsAboutHeader() { const t = useTheme() return ( Discover New Feeds Choose your own timeline! Feeds built by the community help you find content you love. ) } const styles = StyleSheet.create({ contentContainer: { paddingBottom: 100, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 16, paddingHorizontal: 18, paddingVertical: 12, }, savedFeed: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, gap: 12, borderBottomWidth: StyleSheet.hairlineWidth, }, savedFeedMobile: { paddingVertical: 10, }, offlineSlug: { borderWidth: StyleSheet.hairlineWidth, borderRadius: 4, paddingHorizontal: 4, paddingVertical: 2, }, headerBtnGroup: { flexDirection: 'row', gap: 15, alignItems: 'center', }, })