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 resolutionzio/stable
parent
51a3e60132
commit
5751014117
|
@ -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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
}>) {
|
}>) {
|
||||||
|
|
|
@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
|
||||||
)}
|
)}
|
||||||
confirmButtonCta={_(msg`Leave`)}
|
confirmButtonCta={_(msg`Leave`)}
|
||||||
confirmButtonColor="negative"
|
confirmButtonColor="negative"
|
||||||
onConfirm={leaveConvo}
|
onConfirm={() => leaveConvo()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue