Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Eric Bailey and committed by
GitHub
57510141 51a3e601

+222 -21
+198
src/components/FeedCard.tsx
··· 1 + import React from 'react' 2 + import {GestureResponderEvent, View} from 'react-native' 3 + import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' 4 + import {msg, plural, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {logger} from '#/logger' 8 + import { 9 + useAddSavedFeedsMutation, 10 + usePreferencesQuery, 11 + useRemoveFeedMutation, 12 + } from '#/state/queries/preferences' 13 + import {sanitizeHandle} from 'lib/strings/handles' 14 + import {UserAvatar} from '#/view/com/util/UserAvatar' 15 + import * as Toast from 'view/com/util/Toast' 16 + import {useTheme} from '#/alf' 17 + import {atoms as a} from '#/alf' 18 + import {Button, ButtonIcon} from '#/components/Button' 19 + import {useRichText} from '#/components/hooks/useRichText' 20 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 21 + import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 22 + import {Link as InternalLink} from '#/components/Link' 23 + import {Loader} from '#/components/Loader' 24 + import * as Prompt from '#/components/Prompt' 25 + import {RichText} from '#/components/RichText' 26 + import {Text} from '#/components/Typography' 27 + 28 + export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { 29 + return ( 30 + <Link feed={feed}> 31 + <Outer> 32 + <Header> 33 + <Avatar src={feed.avatar} /> 34 + <TitleAndByline title={feed.displayName} creator={feed.creator} /> 35 + <Action uri={feed.uri} pin /> 36 + </Header> 37 + <Description description={feed.description} /> 38 + <Likes count={feed.likeCount || 0} /> 39 + </Outer> 40 + </Link> 41 + ) 42 + } 43 + 44 + export function Link({ 45 + children, 46 + feed, 47 + }: { 48 + children: React.ReactElement 49 + feed: AppBskyFeedDefs.GeneratorView 50 + }) { 51 + const href = React.useMemo(() => { 52 + const urip = new AtUri(feed.uri) 53 + const handleOrDid = feed.creator.handle || feed.creator.did 54 + return `/profile/${handleOrDid}/feed/${urip.rkey}` 55 + }, [feed]) 56 + return <InternalLink to={href}>{children}</InternalLink> 57 + } 58 + 59 + export function Outer({children}: {children: React.ReactNode}) { 60 + return <View style={[a.flex_1, a.gap_md]}>{children}</View> 61 + } 62 + 63 + export function Header({children}: {children: React.ReactNode}) { 64 + return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> 65 + } 66 + 67 + export function Avatar({src}: {src: string | undefined}) { 68 + return <UserAvatar type="algo" size={40} avatar={src} /> 69 + } 70 + 71 + export function TitleAndByline({ 72 + title, 73 + creator, 74 + }: { 75 + title: string 76 + creator: AppBskyActorDefs.ProfileViewBasic 77 + }) { 78 + const t = useTheme() 79 + 80 + return ( 81 + <View style={[a.flex_1]}> 82 + <Text 83 + style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]} 84 + numberOfLines={1}> 85 + {title} 86 + </Text> 87 + <Text 88 + style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]} 89 + numberOfLines={1}> 90 + <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> 91 + </Text> 92 + </View> 93 + ) 94 + } 95 + 96 + export function Description({description}: {description?: string}) { 97 + const [rt, isResolving] = useRichText(description || '') 98 + if (!description) return null 99 + return isResolving ? ( 100 + <RichText value={description} style={[a.leading_snug]} /> 101 + ) : ( 102 + <RichText value={rt} style={[a.leading_snug]} /> 103 + ) 104 + } 105 + 106 + export function Likes({count}: {count: number}) { 107 + const t = useTheme() 108 + return ( 109 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 110 + {plural(count || 0, { 111 + one: 'Liked by # user', 112 + other: 'Liked by # users', 113 + })} 114 + </Text> 115 + ) 116 + } 117 + 118 + export function Action({uri, pin}: {uri: string; pin?: boolean}) { 119 + const {_} = useLingui() 120 + const {data: preferences} = usePreferencesQuery() 121 + const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = 122 + useAddSavedFeedsMutation() 123 + const {isPending: isRemovePending, mutateAsync: removeFeed} = 124 + useRemoveFeedMutation() 125 + const savedFeedConfig = React.useMemo(() => { 126 + return preferences?.savedFeeds?.find( 127 + feed => feed.type === 'feed' && feed.value === uri, 128 + ) 129 + }, [preferences?.savedFeeds, uri]) 130 + const removePromptControl = Prompt.usePromptControl() 131 + const isPending = isAddSavedFeedPending || isRemovePending 132 + 133 + const toggleSave = React.useCallback( 134 + async (e: GestureResponderEvent) => { 135 + e.preventDefault() 136 + e.stopPropagation() 137 + 138 + try { 139 + if (savedFeedConfig) { 140 + await removeFeed(savedFeedConfig) 141 + } else { 142 + await saveFeeds([ 143 + { 144 + type: 'feed', 145 + value: uri, 146 + pinned: pin || false, 147 + }, 148 + ]) 149 + } 150 + Toast.show(_(msg`Feeds updated!`)) 151 + } catch (e: any) { 152 + logger.error(e, {context: `FeedCard: failed to update feeds`, pin}) 153 + Toast.show(_(msg`Failed to update feeds`)) 154 + } 155 + }, 156 + [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig], 157 + ) 158 + 159 + const onPrompRemoveFeed = React.useCallback( 160 + async (e: GestureResponderEvent) => { 161 + e.preventDefault() 162 + e.stopPropagation() 163 + 164 + removePromptControl.open() 165 + }, 166 + [removePromptControl], 167 + ) 168 + 169 + return ( 170 + <> 171 + <Button 172 + disabled={isPending} 173 + label={_(msg`Add this feed to your feeds`)} 174 + size="small" 175 + variant="ghost" 176 + color="secondary" 177 + shape="square" 178 + onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}> 179 + {savedFeedConfig ? ( 180 + <ButtonIcon size="md" icon={isPending ? Loader : Trash} /> 181 + ) : ( 182 + <ButtonIcon size="md" icon={isPending ? Loader : Plus} /> 183 + )} 184 + </Button> 185 + 186 + <Prompt.Basic 187 + control={removePromptControl} 188 + title={_(msg`Remove from my feeds?`)} 189 + description={_( 190 + msg`Are you sure you want to remove this from your feeds?`, 191 + )} 192 + onConfirm={toggleSave} 193 + confirmButtonCta={_(msg`Remove`)} 194 + confirmButtonColor="negative" 195 + /> 196 + </> 197 + ) 198 + }
+10 -7
src/components/Prompt.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {GestureResponderEvent, View} from 'react-native' 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 7 - import {Button, ButtonColor, ButtonText} from '#/components/Button' 7 + import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button' 8 8 import * as Dialog from '#/components/Dialog' 9 9 import {Text} from '#/components/Typography' 10 10 ··· 136 136 * Note: The dialog will close automatically when the action is pressed, you 137 137 * should NOT close the dialog as a side effect of this method. 138 138 */ 139 - onPress: () => void 139 + onPress: ButtonProps['onPress'] 140 140 color?: ButtonColor 141 141 /** 142 142 * Optional i18n string. If undefined, it will default to "Confirm". ··· 147 147 const {_} = useLingui() 148 148 const {gtMobile} = useBreakpoints() 149 149 const {close} = Dialog.useDialogContext() 150 - const handleOnPress = React.useCallback(() => { 151 - close(onPress) 152 - }, [close, onPress]) 150 + const handleOnPress = React.useCallback( 151 + (e: GestureResponderEvent) => { 152 + close(() => onPress?.(e)) 153 + }, 154 + [close, onPress], 155 + ) 153 156 154 157 return ( 155 158 <Button ··· 186 189 * Note: The dialog will close automatically when the action is pressed, you 187 190 * should NOT close the dialog as a side effect of this method. 188 191 */ 189 - onConfirm: () => void 192 + onConfirm: ButtonProps['onPress'] 190 193 confirmButtonColor?: ButtonColor 191 194 showCancel?: boolean 192 195 }>) {
+1 -1
src/components/dms/LeaveConvoPrompt.tsx
··· 49 49 )} 50 50 confirmButtonCta={_(msg`Leave`)} 51 51 confirmButtonColor="negative" 52 - onConfirm={leaveConvo} 52 + onConfirm={() => leaveConvo()} 53 53 /> 54 54 ) 55 55 }
+1 -1
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
··· 333 333 </Trans> 334 334 </Prompt.DescriptionText> 335 335 <Prompt.Actions> 336 - <Prompt.Action onPress={control.close} cta={_(msg`OK`)} /> 336 + <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} /> 337 337 </Prompt.Actions> 338 338 </Prompt.Outer> 339 339 )
+1 -1
src/view/com/util/post-embeds/GifEmbed.tsx
··· 181 181 <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> 182 182 <Prompt.Actions> 183 183 <Prompt.Action 184 - onPress={control.close} 184 + onPress={() => control.close()} 185 185 cta={_(msg`Close`)} 186 186 color="secondary" 187 187 />
+11 -11
src/view/screens/Feeds.tsx
··· 1 1 import React from 'react' 2 2 import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' 3 - import {AppBskyActorDefs} from '@atproto/api' 3 + import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 4 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 5 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' 6 6 import {msg, Trans} from '@lingui/macro' ··· 25 25 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 26 26 import {cleanError} from 'lib/strings/errors' 27 27 import {s} from 'lib/styles' 28 - import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 29 28 import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 30 29 import {FAB} from 'view/com/util/fab/FAB' 31 30 import {SearchInput} from 'view/com/util/forms/SearchInput' ··· 46 45 import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' 47 46 import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' 48 47 import hairlineWidth = StyleSheet.hairlineWidth 48 + import {Divider} from '#/components/Divider' 49 + import * as FeedCard from '#/components/FeedCard' 49 50 50 51 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> 51 52 ··· 94 95 type: 'popularFeed' 95 96 key: string 96 97 feedUri: string 98 + feed: AppBskyFeedDefs.GeneratorView 97 99 } 98 100 | { 99 101 type: 'popularFeedsLoadingMore' ··· 300 302 key: `popularFeed:${feed.uri}`, 301 303 type: 'popularFeed', 302 304 feedUri: feed.uri, 305 + feed, 303 306 })), 304 307 ) 305 308 } ··· 323 326 key: `popularFeed:${feed.uri}`, 324 327 type: 'popularFeed', 325 328 feedUri: feed.uri, 329 + feed, 326 330 })), 327 331 ) 328 332 } ··· 461 465 return ( 462 466 <> 463 467 <FeedsAboutHeader /> 464 - <View style={{paddingHorizontal: 12, paddingBottom: 12}}> 468 + <View style={{paddingHorizontal: 12, paddingBottom: 4}}> 465 469 <SearchInput 466 470 query={query} 467 471 onChangeQuery={onChangeQuery} ··· 476 480 return <FeedFeedLoadingPlaceholder /> 477 481 } else if (item.type === 'popularFeed') { 478 482 return ( 479 - <FeedSourceCard 480 - feedUri={item.feedUri} 481 - showSaveBtn={hasSession} 482 - showDescription 483 - showLikes 484 - pinOnSave 485 - /> 483 + <View style={[a.px_lg, a.pt_lg, a.gap_lg]}> 484 + <FeedCard.Default feed={item.feed} /> 485 + <Divider /> 486 + </View> 486 487 ) 487 488 } else if (item.type === 'popularFeedsNoResults') { 488 489 return ( ··· 525 526 onPressCancelSearch, 526 527 onSubmitQuery, 527 528 onChangeSearchFocus, 528 - hasSession, 529 529 ], 530 530 ) 531 531