diff options
author | Eric Bailey <git@esb.lol> | 2024-06-14 14:24:04 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-06-14 14:24:04 -0500 |
commit | 5751014117ff87a0b5188841b468095f00305ed5 (patch) | |
tree | ea34cf6f99797da7dc05a561e8e5ad4fb753f11b | |
parent | 51a3e601324b4032257e5e932a8eedc39f539975 (diff) | |
download | voidsky-5751014117ff87a0b5188841b468095f00305ed5.tar.zst |
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
-rw-r--r-- | src/components/FeedCard.tsx | 198 | ||||
-rw-r--r-- | src/components/Prompt.tsx | 17 | ||||
-rw-r--r-- | src/components/dms/LeaveConvoPrompt.tsx | 2 | ||||
-rw-r--r-- | src/screens/Profile/Header/ProfileHeaderLabeler.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/GifEmbed.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Feeds.tsx | 22 |
6 files changed, 222 insertions, 21 deletions
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx new file mode 100644 index 000000000..2745ed7c9 --- /dev/null +++ b/src/components/FeedCard.tsx @@ -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" + /> + </> + ) +} diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index d05cab5ab..315ad0dfd 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -1,10 +1,10 @@ import React from 'react' -import {View} from 'react-native' +import {GestureResponderEvent, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' 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 {Text} from '#/components/Typography' @@ -136,7 +136,7 @@ export function Action({ * Note: The dialog will close automatically when the action is pressed, you * should NOT close the dialog as a side effect of this method. */ - onPress: () => void + onPress: ButtonProps['onPress'] color?: ButtonColor /** * Optional i18n string. If undefined, it will default to "Confirm". @@ -147,9 +147,12 @@ export function Action({ const {_} = useLingui() const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() - const handleOnPress = React.useCallback(() => { - close(onPress) - }, [close, onPress]) + const handleOnPress = React.useCallback( + (e: GestureResponderEvent) => { + close(() => onPress?.(e)) + }, + [close, onPress], + ) return ( <Button @@ -186,7 +189,7 @@ export function Basic({ * Note: The dialog will close automatically when the action is pressed, you * should NOT close the dialog as a side effect of this method. */ - onConfirm: () => void + onConfirm: ButtonProps['onPress'] confirmButtonColor?: ButtonColor showCancel?: boolean }>) { diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx index 1c42dbca0..7abc76f34 100644 --- a/src/components/dms/LeaveConvoPrompt.tsx +++ b/src/components/dms/LeaveConvoPrompt.tsx @@ -49,7 +49,7 @@ export function LeaveConvoPrompt({ )} confirmButtonCta={_(msg`Leave`)} confirmButtonColor="negative" - onConfirm={leaveConvo} + onConfirm={() => leaveConvo()} /> ) } diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 64bf71027..6588eb2e1 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -333,7 +333,7 @@ function CantSubscribePrompt({ </Trans> </Prompt.DescriptionText> <Prompt.Actions> - <Prompt.Action onPress={control.close} cta={_(msg`OK`)} /> + <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} /> </Prompt.Actions> </Prompt.Outer> ) diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx index 1c0cf3d39..f2e2a8b0e 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/view/com/util/post-embeds/GifEmbed.tsx @@ -181,7 +181,7 @@ function AltText({text}: {text: string}) { <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> <Prompt.Actions> <Prompt.Action - onPress={control.close} + onPress={() => control.close()} cta={_(msg`Close`)} color="secondary" /> diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 612559455..134521177 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,6 +1,6 @@ import React from 'react' 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 {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' @@ -25,7 +25,6 @@ import {ComposeIcon2} from 'lib/icons' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {cleanError} from 'lib/strings/errors' import {s} from 'lib/styles' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {FAB} from 'view/com/util/fab/FAB' 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 {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' import hairlineWidth = StyleSheet.hairlineWidth +import {Divider} from '#/components/Divider' +import * as FeedCard from '#/components/FeedCard' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> @@ -94,6 +95,7 @@ type FlatlistSlice = type: 'popularFeed' key: string feedUri: string + feed: AppBskyFeedDefs.GeneratorView } | { type: 'popularFeedsLoadingMore' @@ -300,6 +302,7 @@ export function FeedsScreen(_props: Props) { key: `popularFeed:${feed.uri}`, type: 'popularFeed', feedUri: feed.uri, + feed, })), ) } @@ -323,6 +326,7 @@ export function FeedsScreen(_props: Props) { key: `popularFeed:${feed.uri}`, type: 'popularFeed', feedUri: feed.uri, + feed, })), ) } @@ -461,7 +465,7 @@ export function FeedsScreen(_props: Props) { return ( <> <FeedsAboutHeader /> - <View style={{paddingHorizontal: 12, paddingBottom: 12}}> + <View style={{paddingHorizontal: 12, paddingBottom: 4}}> <SearchInput query={query} onChangeQuery={onChangeQuery} @@ -476,13 +480,10 @@ export function FeedsScreen(_props: Props) { return <FeedFeedLoadingPlaceholder /> } else if (item.type === 'popularFeed') { return ( - <FeedSourceCard - feedUri={item.feedUri} - showSaveBtn={hasSession} - showDescription - showLikes - pinOnSave - /> + <View style={[a.px_lg, a.pt_lg, a.gap_lg]}> + <FeedCard.Default feed={item.feed} /> + <Divider /> + </View> ) } else if (item.type === 'popularFeedsNoResults') { return ( @@ -525,7 +526,6 @@ export function FeedsScreen(_props: Props) { onPressCancelSearch, onSubmitQuery, onChangeSearchFocus, - hasSession, ], ) |