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