diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
new file mode 100644
index 00000000..2745ed7c
--- /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 (
+
+
+
+
+
+
+
+ )
+}
+
+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 {children}
+}
+
+export function Outer({children}: {children: React.ReactNode}) {
+ return {children}
+}
+
+export function Header({children}: {children: React.ReactNode}) {
+ return {children}
+}
+
+export function Avatar({src}: {src: string | undefined}) {
+ return
+}
+
+export function TitleAndByline({
+ title,
+ creator,
+}: {
+ title: string
+ creator: AppBskyActorDefs.ProfileViewBasic
+}) {
+ const t = useTheme()
+
+ return (
+
+
+ {title}
+
+
+ Feed by {sanitizeHandle(creator.handle, '@')}
+
+
+ )
+}
+
+export function Description({description}: {description?: string}) {
+ const [rt, isResolving] = useRichText(description || '')
+ if (!description) return null
+ return isResolving ? (
+
+ ) : (
+
+ )
+}
+
+export function Likes({count}: {count: number}) {
+ const t = useTheme()
+ return (
+
+ {plural(count || 0, {
+ one: 'Liked by # user',
+ other: 'Liked by # users',
+ })}
+
+ )
+}
+
+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 (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index d05cab5a..315ad0df 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 (