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