Feed source card (#4512)

* Pass event through click handlers

* Add FeedCard, use in Feeds screen

* Tweak space

* Don't contrain rt height

* Tweak space

* Fix type errors, don't pass event to fns that don't expect it

* Show unresolved RT prior to facet resolution
zio/stable
Eric Bailey 2024-06-14 14:24:04 -05:00 committed by GitHub
parent 51a3e60132
commit 5751014117
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 222 additions and 21 deletions

View File

@ -0,0 +1,198 @@
import React from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {
useAddSavedFeedsMutation,
usePreferencesQuery,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
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 {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}) {
return (
<Link feed={feed}>
<Outer>
<Header>
<Avatar src={feed.avatar} />
<TitleAndByline title={feed.displayName} creator={feed.creator} />
<Action uri={feed.uri} pin />
</Header>
<Description description={feed.description} />
<Likes count={feed.likeCount || 0} />
</Outer>
</Link>
)
}
export function Link({
children,
feed,
}: {
children: React.ReactElement
feed: AppBskyFeedDefs.GeneratorView
}) {
const href = React.useMemo(() => {
const urip = new AtUri(feed.uri)
const handleOrDid = feed.creator.handle || feed.creator.did
return `/profile/${handleOrDid}/feed/${urip.rkey}`
}, [feed])
return <InternalLink to={href}>{children}</InternalLink>
}
export function Outer({children}: {children: React.ReactNode}) {
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
}
export function Header({children}: {children: React.ReactNode}) {
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
}
export function Avatar({src}: {src: string | undefined}) {
return <UserAvatar type="algo" size={40} avatar={src} />
}
export function TitleAndByline({
title,
creator,
}: {
title: string
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}>
{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>
</View>
)
}
export function Description({description}: {description?: string}) {
const [rt, isResolving] = useRichText(description || '')
if (!description) return null
return isResolving ? (
<RichText value={description} style={[a.leading_snug]} />
) : (
<RichText value={rt} style={[a.leading_snug]} />
)
}
export function Likes({count}: {count: number}) {
const t = useTheme()
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{plural(count || 0, {
one: 'Liked by # user',
other: 'Liked by # users',
})}
</Text>
)
}
export function Action({uri, pin}: {uri: string; pin?: boolean}) {
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
useAddSavedFeedsMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
const savedFeedConfig = React.useMemo(() => {
return preferences?.savedFeeds?.find(
feed => feed.type === 'feed' && feed.value === uri,
)
}, [preferences?.savedFeeds, uri])
const removePromptControl = Prompt.usePromptControl()
const isPending = isAddSavedFeedPending || isRemovePending
const toggleSave = React.useCallback(
async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
if (savedFeedConfig) {
await removeFeed(savedFeedConfig)
} else {
await saveFeeds([
{
type: 'feed',
value: uri,
pinned: pin || false,
},
])
}
Toast.show(_(msg`Feeds updated!`))
} catch (e: any) {
logger.error(e, {context: `FeedCard: failed to update feeds`, pin})
Toast.show(_(msg`Failed to update feeds`))
}
},
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig],
)
const onPrompRemoveFeed = React.useCallback(
async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
removePromptControl.open()
},
[removePromptControl],
)
return (
<>
<Button
disabled={isPending}
label={_(msg`Add this feed to your feeds`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
{savedFeedConfig ? (
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
) : (
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
)}
</Button>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from my feeds?`)}
description={_(
msg`Are you sure you want to remove this from your feeds?`,
)}
onConfirm={toggleSave}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
/>
</>
)
}

View File

@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonColor, ButtonText} from '#/components/Button' import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@ -136,7 +136,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you * Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method. * should NOT close the dialog as a side effect of this method.
*/ */
onPress: () => void onPress: ButtonProps['onPress']
color?: ButtonColor color?: ButtonColor
/** /**
* Optional i18n string. If undefined, it will default to "Confirm". * Optional i18n string. If undefined, it will default to "Confirm".
@ -147,9 +147,12 @@ export function Action({
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext() const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => { const handleOnPress = React.useCallback(
close(onPress) (e: GestureResponderEvent) => {
}, [close, onPress]) close(() => onPress?.(e))
},
[close, onPress],
)
return ( return (
<Button <Button
@ -186,7 +189,7 @@ export function Basic({
* Note: The dialog will close automatically when the action is pressed, you * Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method. * should NOT close the dialog as a side effect of this method.
*/ */
onConfirm: () => void onConfirm: ButtonProps['onPress']
confirmButtonColor?: ButtonColor confirmButtonColor?: ButtonColor
showCancel?: boolean showCancel?: boolean
}>) { }>) {

View File

@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
)} )}
confirmButtonCta={_(msg`Leave`)} confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative" confirmButtonColor="negative"
onConfirm={leaveConvo} onConfirm={() => leaveConvo()}
/> />
) )
} }

View File

@ -333,7 +333,7 @@ function CantSubscribePrompt({
</Trans> </Trans>
</Prompt.DescriptionText> </Prompt.DescriptionText>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action onPress={control.close} cta={_(msg`OK`)} /> <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} />
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </Prompt.Outer>
) )

View File

@ -181,7 +181,7 @@ function AltText({text}: {text: string}) {
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action <Prompt.Action
onPress={control.close} onPress={() => control.close()}
cta={_(msg`Close`)} cta={_(msg`Close`)}
color="secondary" color="secondary"
/> />

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@ -25,7 +25,6 @@ import {ComposeIcon2} from 'lib/icons'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {FAB} from 'view/com/util/fab/FAB' import {FAB} from 'view/com/util/fab/FAB'
import {SearchInput} from 'view/com/util/forms/SearchInput' import {SearchInput} from 'view/com/util/forms/SearchInput'
@ -46,6 +45,8 @@ import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/compon
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {Divider} from '#/components/Divider'
import * as FeedCard from '#/components/FeedCard'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
@ -94,6 +95,7 @@ type FlatlistSlice =
type: 'popularFeed' type: 'popularFeed'
key: string key: string
feedUri: string feedUri: string
feed: AppBskyFeedDefs.GeneratorView
} }
| { | {
type: 'popularFeedsLoadingMore' type: 'popularFeedsLoadingMore'
@ -300,6 +302,7 @@ export function FeedsScreen(_props: Props) {
key: `popularFeed:${feed.uri}`, key: `popularFeed:${feed.uri}`,
type: 'popularFeed', type: 'popularFeed',
feedUri: feed.uri, feedUri: feed.uri,
feed,
})), })),
) )
} }
@ -323,6 +326,7 @@ export function FeedsScreen(_props: Props) {
key: `popularFeed:${feed.uri}`, key: `popularFeed:${feed.uri}`,
type: 'popularFeed', type: 'popularFeed',
feedUri: feed.uri, feedUri: feed.uri,
feed,
})), })),
) )
} }
@ -461,7 +465,7 @@ export function FeedsScreen(_props: Props) {
return ( return (
<> <>
<FeedsAboutHeader /> <FeedsAboutHeader />
<View style={{paddingHorizontal: 12, paddingBottom: 12}}> <View style={{paddingHorizontal: 12, paddingBottom: 4}}>
<SearchInput <SearchInput
query={query} query={query}
onChangeQuery={onChangeQuery} onChangeQuery={onChangeQuery}
@ -476,13 +480,10 @@ export function FeedsScreen(_props: Props) {
return <FeedFeedLoadingPlaceholder /> return <FeedFeedLoadingPlaceholder />
} else if (item.type === 'popularFeed') { } else if (item.type === 'popularFeed') {
return ( return (
<FeedSourceCard <View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
feedUri={item.feedUri} <FeedCard.Default feed={item.feed} />
showSaveBtn={hasSession} <Divider />
showDescription </View>
showLikes
pinOnSave
/>
) )
} else if (item.type === 'popularFeedsNoResults') { } else if (item.type === 'popularFeedsNoResults') {
return ( return (
@ -525,7 +526,6 @@ export function FeedsScreen(_props: Props) {
onPressCancelSearch, onPressCancelSearch,
onSubmitQuery, onSubmitQuery,
onChangeSearchFocus, onChangeSearchFocus,
hasSession,
], ],
) )