Bluesky app fork with some witchin' additions 馃挮
at post-text-option 358 lines 9.1 kB view raw
1import React from 'react' 2import {type GestureResponderEvent, View} from 'react-native' 3import { 4 type AppBskyFeedDefs, 5 type AppBskyGraphDefs, 6 AtUri, 7 RichText as RichTextApi, 8} from '@atproto/api' 9import {msg, Plural, Trans} from '@lingui/macro' 10import {useLingui} from '@lingui/react' 11import {useQueryClient} from '@tanstack/react-query' 12 13import {sanitizeHandle} from '#/lib/strings/handles' 14import {logger} from '#/logger' 15import {precacheFeedFromGeneratorView} from '#/state/queries/feed' 16import { 17 useAddSavedFeedsMutation, 18 usePreferencesQuery, 19 useRemoveFeedMutation, 20} from '#/state/queries/preferences' 21import {useSession} from '#/state/session' 22import * as Toast from '#/view/com/util/Toast' 23import {UserAvatar} from '#/view/com/util/UserAvatar' 24import {atoms as a, useTheme} from '#/alf' 25import { 26 Button, 27 ButtonIcon, 28 type ButtonProps, 29 ButtonText, 30} from '#/components/Button' 31import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 32import {Link as InternalLink, type LinkProps} from '#/components/Link' 33import {Loader} from '#/components/Loader' 34import * as Prompt from '#/components/Prompt' 35import {RichText, type RichTextProps} from '#/components/RichText' 36import {Text} from '#/components/Typography' 37import type * as bsky from '#/types/bsky' 38import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash' 39 40type Props = { 41 view: AppBskyFeedDefs.GeneratorView 42 onPress?: () => void 43} 44 45export function Default(props: Props) { 46 const {view} = props 47 return ( 48 <Link {...props}> 49 <Outer> 50 <Header> 51 <Avatar src={view.avatar} /> 52 <TitleAndByline title={view.displayName} creator={view.creator} /> 53 <SaveButton view={view} pin /> 54 </Header> 55 <Description description={view.description} /> 56 <Likes count={view.likeCount || 0} /> 57 </Outer> 58 </Link> 59 ) 60} 61 62export function Link({ 63 view, 64 children, 65 ...props 66}: Props & Omit<LinkProps, 'to' | 'label'>) { 67 const queryClient = useQueryClient() 68 69 const href = React.useMemo(() => { 70 return createProfileFeedHref({feed: view}) 71 }, [view]) 72 73 React.useEffect(() => { 74 precacheFeedFromGeneratorView(queryClient, view) 75 }, [view, queryClient]) 76 77 return ( 78 <InternalLink 79 label={view.displayName} 80 to={href} 81 style={[a.flex_col]} 82 {...props}> 83 {children} 84 </InternalLink> 85 ) 86} 87 88export function Outer({children}: {children: React.ReactNode}) { 89 return <View style={[a.w_full, a.gap_sm]}>{children}</View> 90} 91 92export function Header({children}: {children: React.ReactNode}) { 93 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View> 94} 95 96export type AvatarProps = {src: string | undefined; size?: number} 97 98export function Avatar({src, size = 40}: AvatarProps) { 99 return <UserAvatar type="algo" size={size} avatar={src} /> 100} 101 102export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) { 103 const t = useTheme() 104 return ( 105 <View 106 style={[ 107 t.atoms.bg_contrast_25, 108 { 109 width: size, 110 height: size, 111 borderRadius: 8, 112 }, 113 ]} 114 /> 115 ) 116} 117 118export function TitleAndByline({ 119 title, 120 creator, 121}: { 122 title: string 123 creator?: bsky.profile.AnyProfileView 124}) { 125 const t = useTheme() 126 127 return ( 128 <View style={[a.flex_1]}> 129 <Text 130 emoji 131 style={[a.text_md, a.font_semi_bold, a.leading_snug]} 132 numberOfLines={1}> 133 {title} 134 </Text> 135 {creator && ( 136 <Text 137 style={[a.leading_snug, t.atoms.text_contrast_medium]} 138 numberOfLines={1}> 139 <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> 140 </Text> 141 )} 142 </View> 143 ) 144} 145 146export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) { 147 const t = useTheme() 148 149 return ( 150 <View style={[a.flex_1, a.gap_xs]}> 151 <View 152 style={[ 153 a.rounded_xs, 154 t.atoms.bg_contrast_50, 155 { 156 width: '60%', 157 height: 14, 158 }, 159 ]} 160 /> 161 162 {creator && ( 163 <View 164 style={[ 165 a.rounded_xs, 166 t.atoms.bg_contrast_25, 167 { 168 width: '40%', 169 height: 10, 170 }, 171 ]} 172 /> 173 )} 174 </View> 175 ) 176} 177 178export function Description({ 179 description, 180 ...rest 181}: {description?: string} & Partial<RichTextProps>) { 182 const rt = React.useMemo(() => { 183 if (!description) return 184 const rt = new RichTextApi({text: description || ''}) 185 rt.detectFacetsWithoutResolution() 186 return rt 187 }, [description]) 188 if (!rt) return null 189 return <RichText value={rt} disableLinks {...rest} /> 190} 191 192export function DescriptionPlaceholder() { 193 const t = useTheme() 194 return ( 195 <View style={[a.gap_xs]}> 196 <View 197 style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]} 198 /> 199 <View 200 style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]} 201 /> 202 <View 203 style={[ 204 a.rounded_xs, 205 a.w_full, 206 t.atoms.bg_contrast_50, 207 {height: 12, width: 100}, 208 ]} 209 /> 210 </View> 211 ) 212} 213 214export function Likes({count}: {count: number}) { 215 const t = useTheme() 216 return ( 217 <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_semi_bold]}> 218 <Trans> 219 Liked by <Plural value={count || 0} one="# user" other="# users" /> 220 </Trans> 221 </Text> 222 ) 223} 224 225export function SaveButton({ 226 view, 227 pin, 228 ...props 229}: { 230 view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView 231 pin?: boolean 232 text?: boolean 233} & Partial<ButtonProps>) { 234 const {hasSession} = useSession() 235 if (!hasSession) return null 236 return <SaveButtonInner view={view} pin={pin} {...props} /> 237} 238 239function SaveButtonInner({ 240 view, 241 pin, 242 text = true, 243 ...buttonProps 244}: { 245 view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView 246 pin?: boolean 247 text?: boolean 248} & Partial<ButtonProps>) { 249 const {_} = useLingui() 250 const {data: preferences} = usePreferencesQuery() 251 const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = 252 useAddSavedFeedsMutation() 253 const {isPending: isRemovePending, mutateAsync: removeFeed} = 254 useRemoveFeedMutation() 255 256 const uri = view.uri 257 const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list' 258 259 const savedFeedConfig = React.useMemo(() => { 260 return preferences?.savedFeeds?.find(feed => feed.value === uri) 261 }, [preferences?.savedFeeds, uri]) 262 const removePromptControl = Prompt.usePromptControl() 263 const isPending = isAddSavedFeedPending || isRemovePending 264 265 const toggleSave = React.useCallback( 266 async (e: GestureResponderEvent) => { 267 e.preventDefault() 268 e.stopPropagation() 269 270 try { 271 if (savedFeedConfig) { 272 await removeFeed(savedFeedConfig) 273 } else { 274 await saveFeeds([ 275 { 276 type, 277 value: uri, 278 pinned: pin || false, 279 }, 280 ]) 281 } 282 Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) 283 } catch (err: any) { 284 logger.error(err, {message: `FeedCard: failed to update feeds`, pin}) 285 Toast.show(_(msg`Failed to update feeds`), 'xmark') 286 } 287 }, 288 [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], 289 ) 290 291 const onPrompRemoveFeed = React.useCallback( 292 async (e: GestureResponderEvent) => { 293 e.preventDefault() 294 e.stopPropagation() 295 296 removePromptControl.open() 297 }, 298 [removePromptControl], 299 ) 300 301 return ( 302 <> 303 <Button 304 disabled={isPending} 305 label={_(msg`Add this feed to your feeds`)} 306 size="small" 307 variant="solid" 308 color={savedFeedConfig ? 'secondary' : 'primary'} 309 onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave} 310 {...buttonProps}> 311 {savedFeedConfig ? ( 312 <> 313 {isPending ? ( 314 <ButtonIcon size="md" icon={Loader} /> 315 ) : ( 316 !text && <ButtonIcon size="md" icon={TrashIcon} /> 317 )} 318 {text && ( 319 <ButtonText> 320 <Trans>Unpin Feed</Trans> 321 </ButtonText> 322 )} 323 </> 324 ) : ( 325 <> 326 <ButtonIcon size="md" icon={isPending ? Loader : PinIcon} /> 327 {text && ( 328 <ButtonText> 329 <Trans>Pin Feed</Trans> 330 </ButtonText> 331 )} 332 </> 333 )} 334 </Button> 335 336 <Prompt.Basic 337 control={removePromptControl} 338 title={_(msg`Remove from your feeds?`)} 339 description={_( 340 msg`Are you sure you want to remove this from your feeds?`, 341 )} 342 onConfirm={toggleSave} 343 confirmButtonCta={_(msg`Remove`)} 344 confirmButtonColor="negative" 345 /> 346 </> 347 ) 348} 349 350export function createProfileFeedHref({ 351 feed, 352}: { 353 feed: AppBskyFeedDefs.GeneratorView 354}) { 355 const urip = new AtUri(feed.uri) 356 const handleOrDid = feed.creator.handle || feed.creator.did 357 return `/profile/${handleOrDid}/feed/${urip.rkey}` 358}