Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
117
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 392 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 {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 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 {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 * as Toast from '#/components/Toast' 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 = useMemo(() => { 76 return createProfileFeedHref({feed: view}) 77 }, [view]) 78 79 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 = useMemo(() => { 215 if (!description) return 216 const rt = new RichTextApi({text: description || ''}) 217 detectFacetsWithoutResolution(rt) 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 {t: l} = 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 = 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 = 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(l({message: 'Feeds updated!', context: 'toast'})) 315 } catch (err: any) { 316 logger.error(err, {message: `FeedCard: failed to update feeds`, pin}) 317 Toast.show(l`Failed to update feeds`, { 318 type: 'error', 319 }) 320 } 321 }, 322 [l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], 323 ) 324 325 const onPromptRemoveFeed = useCallback( 326 (e: GestureResponderEvent) => { 327 e.preventDefault() 328 e.stopPropagation() 329 330 removePromptControl.open() 331 }, 332 [removePromptControl], 333 ) 334 335 return ( 336 <> 337 <Button 338 disabled={isPending} 339 label={l`Add this feed to your feeds`} 340 size="small" 341 variant="solid" 342 color={savedFeedConfig ? 'secondary' : 'primary'} 343 onPress={(e: GestureResponderEvent) => 344 savedFeedConfig ? onPromptRemoveFeed(e) : void toggleSave(e) 345 } 346 {...buttonProps}> 347 {savedFeedConfig ? ( 348 <> 349 {isPending ? ( 350 <ButtonIcon size="md" icon={Loader} /> 351 ) : ( 352 !text && <ButtonIcon size="md" icon={TrashIcon} /> 353 )} 354 {text && ( 355 <ButtonText> 356 <Trans>Unpin feed</Trans> 357 </ButtonText> 358 )} 359 </> 360 ) : ( 361 <> 362 <ButtonIcon size="md" icon={isPending ? Loader : PinIcon} /> 363 {text && ( 364 <ButtonText> 365 <Trans>Pin feed</Trans> 366 </ButtonText> 367 )} 368 </> 369 )} 370 </Button> 371 372 <Prompt.Basic 373 control={removePromptControl} 374 title={l`Remove from your feeds?`} 375 description={l`Are you sure you want to remove this from your feeds?`} 376 onConfirm={(e: GestureResponderEvent) => void toggleSave(e)} 377 confirmButtonCta={l`Remove`} 378 confirmButtonColor="negative" 379 /> 380 </> 381 ) 382} 383 384export function createProfileFeedHref({ 385 feed, 386}: { 387 feed: AppBskyFeedDefs.GeneratorView 388}) { 389 const urip = new AtUri(feed.uri) 390 const handleOrDid = feed.creator.handle || feed.creator.did 391 return `/profile/${handleOrDid}/feed/${urip.rkey}` 392}