FeedCard & ListCard cleanups (#4644)

* Extract ListCard from FeedCard

* Export FeedCard.Action and optionally include in ListCard

* Remove list dual usage from most of FeedCard

* Update usages of FeedCard and ListCard

* Add back list purpose logic

* Make Action comp easier to use, clarify list purpose

* Rename Action to SaveButton
zio/stable
Eric Bailey 2024-06-28 08:27:54 -05:00 committed by GitHub
parent 58a97db5b8
commit 1a037d3542
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 198 additions and 97 deletions

View File

@ -18,7 +18,7 @@ import {
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheFeedFromGeneratorView, precacheList} from 'state/queries/feed'
import {precacheFeedFromGeneratorView} from 'state/queries/feed'
import {useSession} from 'state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
@ -33,45 +33,31 @@ import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
type Props =
| {
type: 'feed'
view: AppBskyFeedDefs.GeneratorView
}
| {
type: 'list'
view: AppBskyGraphDefs.ListView
}
type Props = {
view: AppBskyFeedDefs.GeneratorView
}
export function Default(props: Props) {
const {type, view} = props
const displayName = type === 'feed' ? view.displayName : view.name
const purpose = type === 'list' ? view.purpose : undefined
const {view} = props
return (
<Link label={displayName} {...props}>
<Link label={view.displayName} {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline
title={displayName}
creator={view.creator}
type={type}
purpose={purpose}
/>
<Action uri={view.uri} pin type={type} purpose={purpose} />
<TitleAndByline title={view.displayName} creator={view.creator} />
<SaveButton view={view} pin />
</Header>
<Description description={view.description} />
{type === 'feed' && <Likes count={view.likeCount || 0} />}
<Likes count={view.likeCount || 0} />
</Outer>
</Link>
)
}
export function Link({
type,
view,
label,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
const queryClient = useQueryClient()
@ -79,17 +65,12 @@ export function Link({
return createProfileFeedHref({feed: view})
}, [view])
React.useEffect(() => {
precacheFeedFromGeneratorView(queryClient, view)
}, [view, queryClient])
return (
<InternalLink
to={href}
label={label}
onPress={() => {
if (type === 'feed') {
precacheFeedFromGeneratorView(queryClient, view)
} else {
precacheList(queryClient, view)
}
}}>
<InternalLink to={href} {...props}>
{children}
</InternalLink>
)
@ -132,13 +113,9 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({
title,
creator,
type,
purpose,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const t = useTheme()
@ -151,15 +128,7 @@ export function TitleAndByline({
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
</Text>
)}
</View>
@ -221,34 +190,24 @@ export function Likes({count}: {count: number}) {
)
}
export function Action({
uri,
export function SaveButton({
view,
pin,
type,
purpose,
}: {
uri: string
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
pin?: boolean
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const {hasSession} = useSession()
if (
!hasSession ||
(type === 'list' && purpose !== 'app.bsky.graph.defs#curatelist')
)
return null
return <ActionInner uri={uri} pin={pin} type={type} />
if (!hasSession) return null
return <SaveButtonInner view={view} pin={pin} />
}
function ActionInner({
uri,
function SaveButtonInner({
view,
pin,
type,
}: {
uri: string
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
pin?: boolean
type: 'feed' | 'list'
}) {
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
@ -256,6 +215,10 @@ function ActionInner({
useAddSavedFeedsMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
const uri = view.uri
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
const savedFeedConfig = React.useMemo(() => {
return preferences?.savedFeeds?.find(feed => feed.value === uri)
}, [preferences?.savedFeeds, uri])
@ -332,12 +295,9 @@ function ActionInner({
export function createProfileFeedHref({
feed,
}: {
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
feed: AppBskyFeedDefs.GeneratorView
}) {
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
}`
return `/profile/${handleOrDid}/feed/${urip.rkey}`
}

View File

@ -0,0 +1,129 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheList} from 'state/queries/feed'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {
Avatar,
Description,
Header,
Outer,
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
/*
* This component is based on `FeedCard` and is tightly coupled with that
* component. Please refer to `FeedCard` for more context.
*/
export {
Avatar,
AvatarPlaceholder,
Description,
Header,
Outer,
SaveButton,
TitleAndBylinePlaceholder,
} from '#/components/FeedCard'
const CURATELIST = 'app.bsky.graph.defs#curatelist'
const MODLIST = 'app.bsky.graph.defs#modlist'
type Props = {
view: AppBskyGraphDefs.ListView
showPinButton?: boolean
}
export function Default(props: Props) {
const {view, showPinButton} = props
return (
<Link label={view.name} {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline
title={view.name}
creator={view.creator}
purpose={view.purpose}
/>
{showPinButton && view.purpose === CURATELIST && (
<SaveButton view={view} pin />
)}
</Header>
<Description description={view.description} />
</Outer>
</Link>
)
}
export function Link({
view,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
return createProfileListHref({list: view})
}, [view])
React.useEffect(() => {
precacheList(queryClient, view)
}, [view, queryClient])
return (
<InternalLink to={href} {...props}>
{children}
</InternalLink>
)
}
export function TitleAndByline({
title,
creator,
purpose = CURATELIST,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
{title}
</Text>
{creator && (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{purpose === MODLIST ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
</Text>
)}
</View>
)
}
export function createProfileListHref({
list,
}: {
list: AppBskyGraphDefs.ListView
}) {
const urip = new AtUri(list.uri)
const handleOrDid = list.creator.handle || list.creator.did
return `/profile/${handleOrDid}/lists/${urip.rkey}`
}

View File

@ -45,7 +45,7 @@ export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
(isWeb || index !== 0) && a.border_t,
t.atoms.border_contrast_low,
]}>
<FeedCard.Default type="feed" view={item} />
<FeedCard.Default view={item} />
</View>
)
}

View File

@ -316,7 +316,7 @@ function LandingScreenLoaded({
t.atoms.border_contrast_low,
]}
key={feed.uri}>
<FeedCard.Default type="feed" view={feed} />
<FeedCard.Default view={feed} />
</View>
))}
</View>

View File

@ -163,7 +163,7 @@ export const ProfileFeedgens = React.forwardRef<
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default type="feed" view={item} />
<FeedCard.Default view={item} />
</View>
)
}

View File

@ -18,7 +18,7 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {EmptyState} from 'view/com/util/EmptyState'
import {atoms as a, useTheme} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import * as ListCard from '#/components/ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@ -172,7 +172,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default type="list" view={item} />
<ListCard.Default view={item} />
</View>
)
},

View File

@ -41,6 +41,7 @@ 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'
import * as ListCard from '#/components/ListCard'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
@ -495,7 +496,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 type="feed" view={item.feed} />
<FeedCard.Default view={item.feed} />
<Divider />
</View>
)
@ -627,7 +628,7 @@ function FollowingFeed() {
fill={t.palette.white}
/>
</View>
<FeedCard.TitleAndByline title={_(msg`Following`)} type="feed" />
<FeedCard.TitleAndByline title={_(msg`Following`)} />
</FeedCard.Header>
</View>
)
@ -639,34 +640,45 @@ function SavedFeed({
savedFeed: SavedFeedItem & {type: 'feed' | 'list'}
}) {
const t = useTheme()
const {view: feed} = savedFeed
const displayName =
savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name
return (
<FeedCard.Link testID={`saved-feed-${feed.displayName}`} {...savedFeed}>
const commonStyle = [
a.flex_1,
a.px_lg,
a.py_md,
a.border_b,
t.atoms.border_contrast_low,
]
return savedFeed.type === 'feed' ? (
<FeedCard.Link
testID={`saved-feed-${savedFeed.view.displayName}`}
{...savedFeed}>
{({hovered, pressed}) => (
<View
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,
]}>
style={[commonStyle, (hovered || pressed) && t.atoms.bg_contrast_25]}>
<FeedCard.Header>
<FeedCard.Avatar src={feed.avatar} size={28} />
<FeedCard.TitleAndByline
title={displayName}
type={savedFeed.type}
/>
<FeedCard.Avatar src={savedFeed.view.avatar} size={28} />
<FeedCard.TitleAndByline title={savedFeed.view.displayName} />
<ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
</FeedCard.Header>
</View>
)}
</FeedCard.Link>
) : (
<ListCard.Link testID={`saved-feed-${savedFeed.view.name}`} {...savedFeed}>
{({hovered, pressed}) => (
<View
style={[commonStyle, (hovered || pressed) && t.atoms.bg_contrast_25]}>
<ListCard.Header>
<ListCard.Avatar src={savedFeed.view.avatar} size={28} />
<ListCard.TitleAndByline title={savedFeed.view.name} />
<ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
</ListCard.Header>
</View>
)}
</ListCard.Link>
)
}

View File

@ -505,7 +505,7 @@ export function Explore() {
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default type="feed" view={item.feed} />
<FeedCard.Default view={item.feed} />
</View>
)
}

View File

@ -306,7 +306,7 @@ let SearchScreenFeedsResults = ({
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default type="feed" view={item} />
<FeedCard.Default view={item} />
</View>
)}
keyExtractor={item => item.uri}