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