Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at cope-settings-sync 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}