diff options
author | Eric Bailey <git@esb.lol> | 2024-06-21 16:50:23 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-06-22 00:50:23 +0300 |
commit | 4d6787009ccbae2812aaeddefe6dc77742363f36 (patch) | |
tree | 3e7200c5e783e58602e8965b43fe312315db9e7d /src | |
parent | cb376479493dbc3a24876449f6466789ddcef6ea (diff) | |
download | voidsky-4d6787009ccbae2812aaeddefe6dc77742363f36.tar.zst |
Pinned feeds cards (#4526)
* Add lists support to FeedCard * Add useSavedFeeds query, similar to usePinnedFeedInfos * Integrate into Feeds screen * Fix alignment on mobile * Update usages * Add placeholder loading state * Handle no feeds state * Reuse previous data for placeholder * Staged loading * Improve staged loading * Use setQueryData approach to pre-caching * Add types for a little more safety * Fix precaching --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Diffstat (limited to 'src')
-rw-r--r-- | src/components/FeedCard.tsx | 135 | ||||
-rw-r--r-- | src/state/queries/feed.ts | 139 | ||||
-rw-r--r-- | src/state/queries/resolve-uri.ts | 15 | ||||
-rw-r--r-- | src/view/screens/Feeds.tsx | 387 | ||||
-rw-r--r-- | src/view/screens/Search/Explore.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 2 |
6 files changed, 447 insertions, 233 deletions
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index 94d97cb62..bd0649097 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -1,6 +1,11 @@ import React from 'react' import {GestureResponderEvent, View} from 'react-native' -import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyGraphDefs, + AtUri, +} from '@atproto/api' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -20,23 +25,35 @@ import {Button, ButtonIcon} from '#/components/Button' import {useRichText} from '#/components/hooks/useRichText' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Link as InternalLink} from '#/components/Link' +import {Link as InternalLink, LinkProps} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { +export function Default({ + type, + view, +}: + | { + type: 'feed' + view: AppBskyFeedDefs.GeneratorView + } + | { + type: 'list' + view: AppBskyGraphDefs.ListView + }) { + const displayName = type === 'feed' ? view.displayName : view.name return ( - <Link feed={feed}> + <Link feed={view}> <Outer> <Header> - <Avatar src={feed.avatar} /> - <TitleAndByline title={feed.displayName} creator={feed.creator} /> - <Action uri={feed.uri} pin /> + <Avatar src={view.avatar} /> + <TitleAndByline title={displayName} creator={view.creator} /> + <Action uri={view.uri} pin /> </Header> - <Description description={feed.description} /> - <Likes count={feed.likeCount || 0} /> + <Description description={view.description} /> + {type === 'feed' && <Likes count={view.likeCount || 0} />} </Outer> </Link> ) @@ -46,13 +63,10 @@ export function Link({ children, feed, }: { - children: React.ReactElement - feed: AppBskyFeedDefs.GeneratorView -}) { + feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView +} & Omit<LinkProps, 'to'>) { const href = React.useMemo(() => { - const urip = new AtUri(feed.uri) - const handleOrDid = feed.creator.handle || feed.creator.did - return `/profile/${handleOrDid}/feed/${urip.rkey}` + return createProfileFeedHref({feed}) }, [feed]) return <InternalLink to={href}>{children}</InternalLink> } @@ -62,11 +76,33 @@ export function Outer({children}: {children: React.ReactNode}) { } export function Header({children}: {children: React.ReactNode}) { - return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> + return ( + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}> + {children} + </View> + ) +} + +export type AvatarProps = {src: string | undefined; size?: number} + +export function Avatar({src, size = 40}: AvatarProps) { + return <UserAvatar type="algo" size={size} avatar={src} /> } -export function Avatar({src}: {src: string | undefined}) { - return <UserAvatar type="algo" size={40} avatar={src} /> +export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) { + const t = useTheme() + return ( + <View + style={[ + t.atoms.bg_contrast_25, + { + width: size, + height: size, + borderRadius: 8, + }, + ]} + /> + ) } export function TitleAndByline({ @@ -74,22 +110,54 @@ export function TitleAndByline({ creator, }: { title: string - creator: AppBskyActorDefs.ProfileViewBasic + creator?: AppBskyActorDefs.ProfileViewBasic }) { const t = useTheme() return ( <View style={[a.flex_1]}> - <Text - style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]} - numberOfLines={1}> + <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}> {title} </Text> - <Text - style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]} - numberOfLines={1}> - <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> - </Text> + {creator && ( + <Text + style={[a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> + </Text> + )} + </View> + ) +} + +export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) { + const t = useTheme() + + return ( + <View style={[a.flex_1, a.gap_xs]}> + <View + style={[ + a.rounded_xs, + t.atoms.bg_contrast_50, + { + width: '60%', + height: 14, + }, + ]} + /> + + {creator && ( + <View + style={[ + a.rounded_xs, + t.atoms.bg_contrast_25, + { + width: '40%', + height: 10, + }, + ]} + /> + )} </View> ) } @@ -203,3 +271,16 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) { </> ) } + +export function createProfileFeedHref({ + feed, +}: { + feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView +}) { + const urip = new AtUri(feed.uri) + const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list' + const handleOrDid = feed.creator.handle || feed.creator.did + return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${ + urip.rkey + }` +} diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 83d6a7634..972dbf995 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -9,20 +9,24 @@ import { } from '@atproto/api' import { InfiniteData, + QueryClient, QueryKey, useInfiniteQuery, useMutation, useQuery, + useQueryClient, } from '@tanstack/react-query' import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {STALE} from '#/state/queries' +import {RQKEY as listQueryKey} from '#/state/queries/list' import {usePreferencesQuery} from '#/state/queries/preferences' import {useAgent, useSession} from '#/state/session' import {router} from '#/routes' import {FeedDescriptor} from './post-feed' +import {precacheResolvedUri} from './resolve-uri' export type FeedSourceFeedInfo = { type: 'feed' @@ -201,6 +205,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { const agent = useAgent() const limit = options?.limit || 10 const {data: preferences} = usePreferencesQuery() + const queryClient = useQueryClient() // Make sure this doesn't invalidate unless really needed. const selectArgs = useMemo( @@ -225,6 +230,13 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { limit, cursor: pageParam, }) + + // precache feeds + for (const feed of res.data.feeds) { + const hydratedFeed = hydrateFeedGenerator(feed) + precacheFeed(queryClient, hydratedFeed) + } + return res.data }, initialPageParam: undefined, @@ -449,3 +461,130 @@ export function usePinnedFeedsInfos() { }, }) } + +export type SavedFeedItem = + | { + type: 'feed' + config: AppBskyActorDefs.SavedFeed + view: AppBskyFeedDefs.GeneratorView + } + | { + type: 'list' + config: AppBskyActorDefs.SavedFeed + view: AppBskyGraphDefs.ListView + } + | { + type: 'timeline' + config: AppBskyActorDefs.SavedFeed + view: undefined + } + +export function useSavedFeeds() { + const agent = useAgent() + const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() + const savedItems = preferences?.savedFeeds ?? [] + const queryClient = useQueryClient() + + return useQuery({ + staleTime: STALE.INFINITY, + enabled: !isLoadingPrefs, + queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems], + placeholderData: previousData => { + return ( + previousData || { + count: savedItems.length, + feeds: [], + } + ) + }, + queryFn: async () => { + const resolvedFeeds = new Map<string, AppBskyFeedDefs.GeneratorView>() + const resolvedLists = new Map<string, AppBskyGraphDefs.ListView>() + + const savedFeeds = savedItems.filter(feed => feed.type === 'feed') + const savedLists = savedItems.filter(feed => feed.type === 'list') + + let feedsPromise = Promise.resolve() + if (savedFeeds.length > 0) { + feedsPromise = agent.app.bsky.feed + .getFeedGenerators({ + feeds: savedFeeds.map(f => f.value), + }) + .then(res => { + res.data.feeds.forEach(f => { + resolvedFeeds.set(f.uri, f) + }) + }) + } + + const listsPromises = savedLists.map(list => + agent.app.bsky.graph + .getList({ + list: list.value, + limit: 1, + }) + .then(res => { + const listView = res.data.list + resolvedLists.set(listView.uri, listView) + }), + ) + + await Promise.allSettled([feedsPromise, ...listsPromises]) + + resolvedFeeds.forEach(feed => { + const hydratedFeed = hydrateFeedGenerator(feed) + precacheFeed(queryClient, hydratedFeed) + }) + resolvedLists.forEach(list => { + precacheList(queryClient, list) + }) + + const res: SavedFeedItem[] = savedItems.map(s => { + if (s.type === 'timeline') { + return { + type: 'timeline', + config: s, + view: undefined, + } + } + + return { + type: s.type, + config: s, + view: + s.type === 'feed' + ? resolvedFeeds.get(s.value) + : resolvedLists.get(s.value), + } + }) as SavedFeedItem[] + + return { + count: savedItems.length, + feeds: res, + } + }, + }) +} + +function precacheFeed(queryClient: QueryClient, hydratedFeed: FeedSourceInfo) { + precacheResolvedUri( + queryClient, + hydratedFeed.creatorHandle, + hydratedFeed.creatorDid, + ) + queryClient.setQueryData<FeedSourceInfo>( + feedSourceInfoQueryKey({uri: hydratedFeed.uri}), + hydratedFeed, + ) +} + +function precacheList( + queryClient: QueryClient, + list: AppBskyGraphDefs.ListView, +) { + precacheResolvedUri(queryClient, list.creator.handle, list.creator.did) + queryClient.setQueryData<AppBskyGraphDefs.ListView>( + listQueryKey(list.uri), + list, + ) +} diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index 7bd26435c..c1fd8e240 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -1,5 +1,10 @@ import {AppBskyActorDefs, AtUri} from '@atproto/api' -import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query' +import { + QueryClient, + useQuery, + useQueryClient, + UseQueryResult, +} from '@tanstack/react-query' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' @@ -50,3 +55,11 @@ export function useResolveDidQuery(didOrHandle: string | undefined) { enabled: !!didOrHandle, }) } + +export function precacheResolvedUri( + queryClient: QueryClient, + handle: string, + did: string, +) { + queryClient.setQueryData<string>(RQKEY(handle), did) +} diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 134521177..70437a9e7 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,103 @@ 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`)} /> + </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}`} feed={feed}> + {({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} /> + + <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 f6988548b..8f6f6d4ba 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -505,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} |