diff options
Diffstat (limited to 'src/view/screens')
-rw-r--r-- | src/view/screens/Feeds.tsx | 390 | ||||
-rw-r--r-- | src/view/screens/Search/Explore.tsx | 27 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 2 |
3 files changed, 208 insertions, 211 deletions
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 134521177..2e5b48513 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,8 +1,6 @@ import React from 'react' import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' -import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' +import {AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' @@ -10,12 +8,11 @@ import debounce from 'lodash.debounce' import {isNative, isWeb} from '#/platform/detection' import { - getAvatarTypeFromUri, - useFeedSourceInfoQuery, + SavedFeedItem, useGetPopularFeedsQuery, + useSavedFeeds, useSearchPopularFeedsMutation, } from '#/state/queries/feed' -import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' @@ -28,14 +25,10 @@ import {s} from 'lib/styles' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {FAB} from 'view/com/util/fab/FAB' import {SearchInput} from 'view/com/util/forms/SearchInput' -import {Link, TextLink} from 'view/com/util/Link' +import {TextLink} from 'view/com/util/Link' import {List} from 'view/com/util/List' -import { - FeedFeedLoadingPlaceholder, - LoadingPlaceholder, -} from 'view/com/util/LoadingPlaceholder' +import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {Text} from 'view/com/util/text/Text' -import {UserAvatar} from 'view/com/util/UserAvatar' import {ViewHeader} from 'view/com/util/ViewHeader' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' @@ -47,6 +40,7 @@ import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkl import hairlineWidth = StyleSheet.hairlineWidth import {Divider} from '#/components/Divider' import * as FeedCard from '#/components/FeedCard' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> @@ -61,9 +55,8 @@ type FlatlistSlice = key: string } | { - type: 'savedFeedsLoading' + type: 'savedFeedPlaceholder' key: string - // pendingItems: number, } | { type: 'savedFeedNoResults' @@ -72,8 +65,7 @@ type FlatlistSlice = | { type: 'savedFeed' key: string - feedUri: string - savedFeedConfig: AppBskyActorDefs.SavedFeed + savedFeed: SavedFeedItem } | { type: 'savedFeedsLoadMore' @@ -113,11 +105,11 @@ export function FeedsScreen(_props: Props) { const [query, setQuery] = React.useState('') const [isPTR, setIsPTR] = React.useState(false) const { - data: preferences, - isLoading: isPreferencesLoading, - error: preferencesError, - refetch: refetchPreferences, - } = usePreferencesQuery() + data: savedFeeds, + isPlaceholderData: isSavedFeedsPlaceholder, + error: savedFeedsError, + refetch: refetchSavedFeeds, + } = useSavedFeeds() const { data: popularFeeds, isFetching: isPopularFeedsFetching, @@ -173,11 +165,11 @@ export function FeedsScreen(_props: Props) { const onPullToRefresh = React.useCallback(async () => { setIsPTR(true) await Promise.all([ - refetchPreferences().catch(_e => undefined), + refetchSavedFeeds().catch(_e => undefined), refetchPopularFeeds().catch(_e => undefined), ]) setIsPTR(false) - }, [setIsPTR, refetchPreferences, refetchPopularFeeds]) + }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds]) const onEndReached = React.useCallback(() => { if ( isPopularFeedsFetching || @@ -203,6 +195,11 @@ export function FeedsScreen(_props: Props) { const items = React.useMemo(() => { let slices: FlatlistSlice[] = [] + const hasActualSavedCount = + !isSavedFeedsPlaceholder || + (isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0) + const canShowDiscoverSection = + !hasSession || (hasSession && hasActualSavedCount) if (hasSession) { slices.push({ @@ -210,47 +207,63 @@ export function FeedsScreen(_props: Props) { type: 'savedFeedsHeader', }) - if (preferencesError) { + if (savedFeedsError) { slices.push({ key: 'savedFeedsError', type: 'error', - error: cleanError(preferencesError.toString()), + error: cleanError(savedFeedsError.toString()), }) } else { - if (isPreferencesLoading || !preferences?.savedFeeds) { - slices.push({ - key: 'savedFeedsLoading', - type: 'savedFeedsLoading', - // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, - }) + 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 (preferences.savedFeeds?.length) { - const noFollowingFeed = preferences.savedFeeds.every( + if (savedFeeds?.feeds?.length) { + const noFollowingFeed = savedFeeds.feeds.every( f => f.type !== 'timeline', ) slices = slices.concat( - preferences.savedFeeds - .filter(f => { - return f.pinned + savedFeeds.feeds + .filter(s => { + return s.config.pinned }) - .map(feed => ({ - key: `savedFeed:${feed.value}:${feed.id}`, + .map(s => ({ + key: `savedFeed:${s.view?.uri}:${s.config.id}`, type: 'savedFeed', - feedUri: feed.value, - savedFeedConfig: feed, + savedFeed: s, })), ) slices = slices.concat( - preferences.savedFeeds - .filter(f => { - return !f.pinned + savedFeeds.feeds + .filter(s => { + return !s.config.pinned }) - .map(feed => ({ - key: `savedFeed:${feed.value}:${feed.id}`, + .map(s => ({ + key: `savedFeed:${s.view?.uri}:${s.config.id}`, type: 'savedFeed', - feedUri: feed.value, - savedFeedConfig: feed, + savedFeed: s, })), ) @@ -270,59 +283,36 @@ export function FeedsScreen(_props: Props) { } } - slices.push({ - key: 'popularFeedsHeader', - type: 'popularFeedsHeader', - }) - - if (popularFeedsError || searchError) { + if (!hasSession || (hasSession && canShowDiscoverSection)) { slices.push({ - key: 'popularFeedsError', - type: 'error', - error: cleanError( - popularFeedsError?.toString() ?? searchError?.toString() ?? '', - ), + key: 'popularFeedsHeader', + type: 'popularFeedsHeader', }) - } 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, - })), - ) - } - } + + if (popularFeedsError || searchError) { + slices.push({ + key: 'popularFeedsError', + type: 'error', + error: cleanError( + popularFeedsError?.toString() ?? searchError?.toString() ?? '', + ), + }) } else { - if (isPopularFeedsFetching && !popularFeeds?.pages) { - slices.push({ - key: 'popularFeedsLoading', - type: 'popularFeedsLoading', - }) - } else { - if (!popularFeeds?.pages) { + if (isUserSearching) { + if (isSearchPending || !searchResults) { slices.push({ - key: 'popularFeedsNoResults', - type: 'popularFeedsNoResults', + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', }) } else { - for (const page of popularFeeds.pages || []) { + if (!searchResults || searchResults?.length === 0) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { slices = slices.concat( - page.feeds.map(feed => ({ + searchResults.map(feed => ({ key: `popularFeed:${feed.uri}`, type: 'popularFeed', feedUri: feed.uri, @@ -330,12 +320,37 @@ export function FeedsScreen(_props: Props) { })), ) } - - if (isPopularFeedsFetchingNextPage) { + } + } else { + if (isPopularFeedsFetching && !popularFeeds?.pages) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if (!popularFeeds?.pages) { slices.push({ - key: 'popularFeedsLoadingMore', - type: 'popularFeedsLoadingMore', + 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', + }) + } } } } @@ -345,9 +360,9 @@ export function FeedsScreen(_props: Props) { return slices }, [ hasSession, - preferences, - isPreferencesLoading, - preferencesError, + savedFeeds, + isSavedFeedsPlaceholder, + savedFeedsError, popularFeeds, isPopularFeedsFetching, popularFeedsError, @@ -407,10 +422,7 @@ export function FeedsScreen(_props: Props) { ({item}: {item: FlatlistSlice}) => { if (item.type === 'error') { return <ErrorMessage message={item.error} /> - } else if ( - item.type === 'popularFeedsLoadingMore' || - item.type === 'savedFeedsLoading' - ) { + } else if (item.type === 'popularFeedsLoadingMore') { return ( <View style={s.p10}> <ActivityIndicator size="large" /> @@ -459,8 +471,10 @@ export function FeedsScreen(_props: Props) { <NoSavedFeedsOfAnyType /> </View> ) + } else if (item.type === 'savedFeedPlaceholder') { + return <SavedFeedPlaceholder /> } else if (item.type === 'savedFeed') { - return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} /> + return <FeedOrFollowing savedFeed={item.savedFeed} /> } else if (item.type === 'popularFeedsHeader') { return ( <> @@ -481,7 +495,7 @@ export function FeedsScreen(_props: Props) { } else if (item.type === 'popularFeed') { return ( <View style={[a.px_lg, a.pt_lg, a.gap_lg]}> - <FeedCard.Default feed={item.feed} /> + <FeedCard.Default type="feed" view={item.feed} /> <Divider /> </View> ) @@ -571,136 +585,106 @@ export function FeedsScreen(_props: Props) { ) } -function FeedOrFollowing({ - savedFeedConfig: feed, -}: { - savedFeedConfig: AppBskyActorDefs.SavedFeed -}) { - return feed.type === 'timeline' ? ( +function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) { + return savedFeed.type === 'timeline' ? ( <FollowingFeed /> ) : ( - <SavedFeed savedFeedConfig={feed} /> + <SavedFeed savedFeed={savedFeed} /> ) } function FollowingFeed() { - const pal = usePalette('default') const t = useTheme() - const {isMobile} = useWebMediaQueries() + const {_} = useLingui() return ( <View - testID={`saved-feed-timeline`} style={[ - pal.border, - styles.savedFeed, - isMobile && styles.savedFeedMobile, + a.flex_1, + a.px_lg, + a.py_md, + a.border_b, + t.atoms.border_contrast_low, ]}> - <View - style={[ - a.align_center, - a.justify_center, - { - width: 28, - height: 28, - borderRadius: 3, - backgroundColor: t.palette.primary_500, - }, - ]}> - <FilterTimeline + <FeedCard.Header> + <View style={[ + a.align_center, + a.justify_center, { - width: 18, - height: 18, + width: 28, + height: 28, + borderRadius: 3, + backgroundColor: t.palette.primary_500, }, - ]} - fill={t.palette.white} - /> - </View> - <View - style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> - <Text type="lg-medium" style={pal.text} numberOfLines={1}> - <Trans>Following</Trans> - </Text> - </View> + ]}> + <FilterTimeline + style={[ + { + width: 18, + height: 18, + }, + ]} + fill={t.palette.white} + /> + </View> + <FeedCard.TitleAndByline title={_(msg`Following`)} type="feed" /> + </FeedCard.Header> </View> ) } function SavedFeed({ - savedFeedConfig: feed, + savedFeed, }: { - savedFeedConfig: AppBskyActorDefs.SavedFeed + savedFeed: SavedFeedItem & {type: 'feed' | 'list'} }) { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() - const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value}) - const typeAvatar = getAvatarTypeFromUri(feed.value) - - if (!info) - return ( - <SavedFeedLoadingPlaceholder - key={`savedFeedLoadingPlaceholder:${feed.value}`} - /> - ) + const t = useTheme() + const {view: feed} = savedFeed + const displayName = + savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name return ( - <Link - testID={`saved-feed-${info.displayName}`} - href={info.route.href} - style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} - hoverStyle={pal.viewLight} - accessibilityLabel={info.displayName} - accessibilityHint="" - asAnchor - anchorNoUnderline> - {error ? ( + <FeedCard.Link testID={`saved-feed-${feed.displayName}`} {...savedFeed}> + {({hovered, pressed}) => ( <View - style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> - <FontAwesomeIcon - icon="exclamation-circle" - color={pal.colors.textLight} - /> + style={[ + a.flex_1, + a.px_lg, + a.py_md, + a.border_b, + t.atoms.border_contrast_low, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <FeedCard.Header> + <FeedCard.Avatar src={feed.avatar} size={28} /> + <FeedCard.TitleAndByline + title={displayName} + type={savedFeed.type} + /> + + <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} /> + </FeedCard.Header> </View> - ) : ( - <UserAvatar type={typeAvatar} size={28} avatar={info.avatar} /> )} - <View - style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> - <Text type="lg-medium" style={pal.text} numberOfLines={1}> - {info.displayName} - </Text> - {error ? ( - <View style={[styles.offlineSlug, pal.borderDark]}> - <Text type="xs" style={pal.textLight}> - <Trans>Feed offline</Trans> - </Text> - </View> - ) : null} - </View> - - {isMobile && ( - <FontAwesomeIcon - icon="chevron-right" - size={14} - style={pal.textLight as FontAwesomeIconStyle} - /> - )} - </Link> + </FeedCard.Link> ) } -function SavedFeedLoadingPlaceholder() { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() +function SavedFeedPlaceholder() { + const t = useTheme() return ( <View style={[ - pal.border, - styles.savedFeed, - isMobile && styles.savedFeedMobile, + a.flex_1, + a.px_lg, + a.py_md, + a.border_b, + t.atoms.border_contrast_low, ]}> - <LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} /> - <LoadingPlaceholder width={140} height={12} /> + <FeedCard.Header> + <FeedCard.AvatarPlaceholder size={28} /> + <FeedCard.TitleAndBylinePlaceholder /> + </FeedCard.Header> </View> ) } diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index dd93bf813..8f6f6d4ba 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -282,7 +282,7 @@ export function Explore() { isFetchingNextPage: isFetchingNextProfilesPage, error: profilesError, fetchNextPage: fetchNextProfilesPage, - } = useSuggestedFollowsQuery({limit: 3}) + } = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10}) const { data: feeds, hasNextPage: hasNextFeedsPage, @@ -290,7 +290,7 @@ export function Explore() { isFetchingNextPage: isFetchingNextFeedsPage, error: feedsError, fetchNextPage: fetchNextFeedsPage, - } = useGetPopularFeedsQuery({limit: 3}) + } = useGetPopularFeedsQuery({limit: 10}) const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles const onLoadMoreProfiles = React.useCallback(async () => { @@ -340,11 +340,12 @@ export function Explore() { // Currently the responses contain duplicate items. // Needs to be fixed on backend, but let's dedupe to be safe. let seen = new Set() + const profileItems: ExploreScreenItems[] = [] for (const page of profiles.pages) { for (const actor of page.actors) { if (!seen.has(actor.did)) { seen.add(actor.did) - i.push({ + profileItems.push({ type: 'profile', key: actor.did, profile: actor, @@ -354,13 +355,19 @@ export function Explore() { } if (hasNextProfilesPage) { + // splice off 3 as previews if we have a next page + const previews = profileItems.splice(-3) + // push remainder + i.push(...profileItems) i.push({ type: 'loadMore', key: 'loadMoreProfiles', isLoadingMore: isLoadingMoreProfiles, onLoadMore: onLoadMoreProfiles, - items: i.filter(item => item.type === 'profile').slice(-3), + items: previews, }) + } else { + i.push(...profileItems) } } else { if (profilesError) { @@ -390,11 +397,12 @@ export function Explore() { // Currently the responses contain duplicate items. // Needs to be fixed on backend, but let's dedupe to be safe. let seen = new Set() + const feedItems: ExploreScreenItems[] = [] for (const page of feeds.pages) { for (const feed of page.feeds) { if (!seen.has(feed.uri)) { seen.add(feed.uri) - i.push({ + feedItems.push({ type: 'feed', key: feed.uri, feed, @@ -403,6 +411,7 @@ export function Explore() { } } + // feeds errors can occur during pagination, so feeds is truthy if (feedsError) { i.push({ type: 'error', @@ -418,13 +427,17 @@ export function Explore() { error: cleanError(preferencesError), }) } else if (hasNextFeedsPage) { + const preview = feedItems.splice(-3) + i.push(...feedItems) i.push({ type: 'loadMore', key: 'loadMoreFeeds', isLoadingMore: isLoadingMoreFeeds, onLoadMore: onLoadMoreFeeds, - items: i.filter(item => item.type === 'feed').slice(-3), + items: preview, }) + } else { + i.push(...feedItems) } } else { if (feedsError) { @@ -492,7 +505,7 @@ export function Explore() { a.px_lg, a.py_lg, ]}> - <FeedCard.Default feed={item.feed} /> + <FeedCard.Default type="feed" view={item.feed} /> </View> ) } diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 0b1fe37aa..76ffba935 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -306,7 +306,7 @@ let SearchScreenFeedsResults = ({ a.px_lg, a.py_lg, ]}> - <FeedCard.Default feed={item} /> + <FeedCard.Default type="feed" view={item} /> </View> )} keyExtractor={item => item.uri} |