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

Configure Feed

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

at main 623 lines 19 kB view raw
1import {useState} from 'react' 2import {View} from 'react-native' 3import type Animated from 'react-native-reanimated' 4import {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated' 5import {type AppBskyActorDefs} from '@atproto/api' 6import {TID} from '@atproto/common-web' 7import {msg} from '@lingui/core/macro' 8import {useLingui} from '@lingui/react' 9import {Trans} from '@lingui/react/macro' 10import {useNavigation} from '@react-navigation/native' 11import {type NativeStackScreenProps} from '@react-navigation/native-stack' 12 13import {RECOMMENDED_SAVED_FEEDS, TIMELINE_SAVED_FEED} from '#/lib/constants' 14import {useHaptics} from '#/lib/haptics' 15import { 16 type CommonNavigatorParams, 17 type NavigationProp, 18} from '#/lib/routes/types' 19import {logger} from '#/logger' 20import {useA11y} from '#/state/a11y' 21import { 22 useOverwriteSavedFeedsMutation, 23 usePreferencesQuery, 24} from '#/state/queries/preferences' 25import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 26import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' 27import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 28import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' 29import {atoms as a, useBreakpoints, useTheme} from '#/alf' 30import {Admonition} from '#/components/Admonition' 31import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32import {SortableList} from '#/components/DraggableList' 33import { 34 ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon, 35 ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon, 36} from '#/components/icons/Arrow' 37import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 38import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' 39import {Pin_Filled_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 40import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 41import * as Layout from '#/components/Layout' 42import {InlineLinkText} from '#/components/Link' 43import {Loader} from '#/components/Loader' 44import * as Toast from '#/components/Toast' 45import {Text} from '#/components/Typography' 46 47type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> 48export function SavedFeeds({}: Props) { 49 const {data: preferences} = usePreferencesQuery() 50 const {screenReaderEnabled} = useA11y() 51 if (!preferences) { 52 return <View /> 53 } 54 if (screenReaderEnabled) { 55 return <SavedFeedsA11y preferences={preferences} /> 56 } 57 return <SavedFeedsInner preferences={preferences} /> 58} 59 60function SavedFeedsInner({ 61 preferences, 62}: { 63 preferences: UsePreferencesQueryResponse 64}) { 65 const t = useTheme() 66 const {_} = useLingui() 67 const {gtMobile} = useBreakpoints() 68 const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = 69 useOverwriteSavedFeedsMutation() 70 const navigation = useNavigation<NavigationProp>() 71 const scrollRef = useAnimatedRef<Animated.ScrollView>() 72 const scrollOffset = useScrollViewOffset(scrollRef) 73 74 /* 75 * Use optimistic data if exists and no error, otherwise fallback to remote 76 * data 77 */ 78 const [currentFeeds, setCurrentFeeds] = useState( 79 () => preferences.savedFeeds || [], 80 ) 81 const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds 82 const pinnedFeeds = currentFeeds.filter(f => f.pinned) 83 const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) 84 const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 85 const noFollowingFeed = 86 currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType 87 const [isDragging, setIsDragging] = useState(false) 88 89 const onSaveChanges = async () => { 90 try { 91 await overwriteSavedFeeds(currentFeeds) 92 Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) 93 if (navigation.canGoBack()) { 94 navigation.goBack() 95 } else { 96 navigation.navigate('Feeds') 97 } 98 } catch (e) { 99 Toast.show(_(msg`There was an issue contacting the server`), { 100 type: 'error', 101 }) 102 logger.error('Failed to toggle pinned feed', {message: e}) 103 } 104 } 105 106 return ( 107 <Layout.Screen> 108 <Layout.Header.Outer> 109 <Layout.Header.BackButton /> 110 <Layout.Header.Content align="left"> 111 <Layout.Header.TitleText> 112 <Trans>Feeds</Trans> 113 </Layout.Header.TitleText> 114 </Layout.Header.Content> 115 <Button 116 testID="saveChangesBtn" 117 size="small" 118 color={hasUnsavedChanges ? 'primary' : 'secondary'} 119 onPress={onSaveChanges} 120 label={_(msg`Save changes`)} 121 disabled={isOverwritePending || !hasUnsavedChanges}> 122 <ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} /> 123 <ButtonText> 124 {gtMobile ? <Trans>Save changes</Trans> : <Trans>Save</Trans>} 125 </ButtonText> 126 </Button> 127 </Layout.Header.Outer> 128 129 <Layout.Content ref={scrollRef} scrollEnabled={!isDragging}> 130 {noSavedFeedsOfAnyType && ( 131 <View style={[t.atoms.border_contrast_low, a.border_b]}> 132 <NoSavedFeedsOfAnyType 133 onAddRecommendedFeeds={() => 134 setCurrentFeeds( 135 RECOMMENDED_SAVED_FEEDS.map(f => ({ 136 ...f, 137 id: TID.nextStr(), 138 })), 139 ) 140 } 141 /> 142 </View> 143 )} 144 145 <SectionHeaderText> 146 <Trans>Pinned Feeds</Trans> 147 </SectionHeaderText> 148 149 {preferences ? ( 150 !pinnedFeeds.length ? ( 151 <View style={[a.flex_1, a.p_lg]}> 152 <Admonition type="info"> 153 <Trans>You don't have any pinned feeds.</Trans> 154 </Admonition> 155 </View> 156 ) : ( 157 <SortableList 158 data={pinnedFeeds} 159 keyExtractor={f => f.id} 160 itemHeight={68} 161 scrollRef={scrollRef} 162 scrollOffset={scrollOffset} 163 onDragStart={() => setIsDragging(true)} 164 onDragEnd={() => setIsDragging(false)} 165 onReorder={reordered => { 166 setCurrentFeeds([...reordered, ...unpinnedFeeds]) 167 }} 168 renderItem={(feed, dragHandle) => ( 169 <PinnedFeedItem 170 feed={feed} 171 currentFeeds={currentFeeds} 172 setCurrentFeeds={setCurrentFeeds} 173 dragHandle={dragHandle} 174 /> 175 )} 176 /> 177 ) 178 ) : ( 179 <View style={[a.w_full, a.py_2xl, a.align_center]}> 180 <Loader size="xl" /> 181 </View> 182 )} 183 184 {noFollowingFeed && ( 185 <View style={[t.atoms.border_contrast_low, a.border_b]}> 186 <NoFollowingFeed 187 onAddFeed={() => 188 setCurrentFeeds(feeds => [ 189 ...feeds, 190 {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, 191 ]) 192 } 193 /> 194 </View> 195 )} 196 197 <SectionHeaderText> 198 <Trans>Saved Feeds</Trans> 199 </SectionHeaderText> 200 201 {preferences ? ( 202 !unpinnedFeeds.length ? ( 203 <View style={[a.flex_1, a.p_lg]}> 204 <Admonition type="info"> 205 <Trans>You don't have any saved feeds.</Trans> 206 </Admonition> 207 </View> 208 ) : ( 209 unpinnedFeeds.map(f => ( 210 <UnpinnedFeedItem 211 key={f.id} 212 feed={f} 213 currentFeeds={currentFeeds} 214 setCurrentFeeds={setCurrentFeeds} 215 /> 216 )) 217 ) 218 ) : ( 219 <View style={[a.w_full, a.py_2xl, a.align_center]}> 220 <Loader size="xl" /> 221 </View> 222 )} 223 224 <View style={[a.px_lg, a.py_xl]}> 225 <Text 226 style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}> 227 <Trans> 228 Feeds are custom algorithms that users build with a little coding 229 expertise.{' '} 230 <InlineLinkText 231 to="https://github.com/bluesky-social/feed-generator" 232 label={_(msg`See this guide`)} 233 disableMismatchWarning 234 style={[a.leading_snug]}> 235 See this guide 236 </InlineLinkText>{' '} 237 for more information. 238 </Trans> 239 </Text> 240 </View> 241 </Layout.Content> 242 </Layout.Screen> 243 ) 244} 245 246function SavedFeedsA11y({ 247 preferences, 248}: { 249 preferences: UsePreferencesQueryResponse 250}) { 251 const t = useTheme() 252 const {_} = useLingui() 253 const {gtMobile} = useBreakpoints() 254 const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = 255 useOverwriteSavedFeedsMutation() 256 const navigation = useNavigation<NavigationProp>() 257 258 const [currentFeeds, setCurrentFeeds] = useState( 259 () => preferences.savedFeeds || [], 260 ) 261 const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds 262 const pinnedFeeds = currentFeeds.filter(f => f.pinned) 263 const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) 264 const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 265 const noFollowingFeed = 266 currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType 267 268 const onSaveChanges = async () => { 269 try { 270 await overwriteSavedFeeds(currentFeeds) 271 Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) 272 if (navigation.canGoBack()) { 273 navigation.goBack() 274 } else { 275 navigation.navigate('Feeds') 276 } 277 } catch (e) { 278 Toast.show(_(msg`There was an issue contacting the server`), { 279 type: 'error', 280 }) 281 logger.error('Failed to toggle pinned feed', {message: e}) 282 } 283 } 284 285 const onMoveUp = (index: number) => { 286 const pinned = [...pinnedFeeds] 287 ;[pinned[index - 1], pinned[index]] = [pinned[index], pinned[index - 1]] 288 setCurrentFeeds([...pinned, ...unpinnedFeeds]) 289 } 290 291 const onMoveDown = (index: number) => { 292 const pinned = [...pinnedFeeds] 293 ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] 294 setCurrentFeeds([...pinned, ...unpinnedFeeds]) 295 } 296 297 return ( 298 <Layout.Screen> 299 <Layout.Header.Outer> 300 <Layout.Header.BackButton /> 301 <Layout.Header.Content align="left"> 302 <Layout.Header.TitleText> 303 <Trans>Feeds</Trans> 304 </Layout.Header.TitleText> 305 </Layout.Header.Content> 306 <Button 307 testID="saveChangesBtn" 308 size="small" 309 color={hasUnsavedChanges ? 'primary' : 'secondary'} 310 onPress={onSaveChanges} 311 label={_(msg`Save changes`)} 312 disabled={isOverwritePending || !hasUnsavedChanges}> 313 <ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} /> 314 <ButtonText> 315 {gtMobile ? <Trans>Save changes</Trans> : <Trans>Save</Trans>} 316 </ButtonText> 317 </Button> 318 </Layout.Header.Outer> 319 320 <Layout.Content> 321 {noSavedFeedsOfAnyType && ( 322 <View style={[t.atoms.border_contrast_low, a.border_b]}> 323 <NoSavedFeedsOfAnyType 324 onAddRecommendedFeeds={() => 325 setCurrentFeeds( 326 RECOMMENDED_SAVED_FEEDS.map(f => ({ 327 ...f, 328 id: TID.nextStr(), 329 })), 330 ) 331 } 332 /> 333 </View> 334 )} 335 336 <SectionHeaderText> 337 <Trans>Pinned Feeds</Trans> 338 </SectionHeaderText> 339 340 {!pinnedFeeds.length ? ( 341 <View style={[a.flex_1, a.p_lg]}> 342 <Admonition type="info"> 343 <Trans>You don't have any pinned feeds.</Trans> 344 </Admonition> 345 </View> 346 ) : ( 347 pinnedFeeds.map((feed, i) => ( 348 <PinnedFeedItem 349 key={feed.id} 350 feed={feed} 351 currentFeeds={currentFeeds} 352 setCurrentFeeds={setCurrentFeeds} 353 index={i} 354 total={pinnedFeeds.length} 355 onMoveUp={() => onMoveUp(i)} 356 onMoveDown={() => onMoveDown(i)} 357 /> 358 )) 359 )} 360 361 {noFollowingFeed && ( 362 <View style={[t.atoms.border_contrast_low, a.border_b]}> 363 <NoFollowingFeed 364 onAddFeed={() => 365 setCurrentFeeds(feeds => [ 366 ...feeds, 367 {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, 368 ]) 369 } 370 /> 371 </View> 372 )} 373 374 <SectionHeaderText> 375 <Trans>Saved Feeds</Trans> 376 </SectionHeaderText> 377 378 {!unpinnedFeeds.length ? ( 379 <View style={[a.flex_1, a.p_lg]}> 380 <Admonition type="info"> 381 <Trans>You don't have any saved feeds.</Trans> 382 </Admonition> 383 </View> 384 ) : ( 385 unpinnedFeeds.map(f => ( 386 <UnpinnedFeedItem 387 key={f.id} 388 feed={f} 389 currentFeeds={currentFeeds} 390 setCurrentFeeds={setCurrentFeeds} 391 /> 392 )) 393 )} 394 395 <View style={[a.px_lg, a.py_xl]}> 396 <Text 397 style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}> 398 <Trans> 399 Feeds are custom algorithms that users build with a little coding 400 expertise.{' '} 401 <InlineLinkText 402 to="https://github.com/bluesky-social/feed-generator" 403 label={_(msg`See this guide`)} 404 disableMismatchWarning 405 style={[a.leading_snug]}> 406 See this guide 407 </InlineLinkText>{' '} 408 for more information. 409 </Trans> 410 </Text> 411 </View> 412 </Layout.Content> 413 </Layout.Screen> 414 ) 415} 416 417function PinnedFeedItem({ 418 feed, 419 currentFeeds, 420 setCurrentFeeds, 421 dragHandle, 422 index, 423 total, 424 onMoveUp, 425 onMoveDown, 426}: { 427 feed: AppBskyActorDefs.SavedFeed 428 currentFeeds: AppBskyActorDefs.SavedFeed[] 429 setCurrentFeeds: React.Dispatch< 430 React.SetStateAction<AppBskyActorDefs.SavedFeed[]> 431 > 432 dragHandle?: React.ReactNode 433 index?: number 434 total?: number 435 onMoveUp?: () => void 436 onMoveDown?: () => void 437}) { 438 const {_} = useLingui() 439 const t = useTheme() 440 const playHaptic = useHaptics() 441 const feedUri = feed.value 442 443 const onTogglePinned = () => { 444 playHaptic() 445 setCurrentFeeds( 446 currentFeeds.map(f => 447 f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, 448 ), 449 ) 450 } 451 452 return ( 453 <View style={[a.flex_row, t.atoms.bg]}> 454 {feed.type === 'timeline' ? ( 455 <FollowingFeedCard /> 456 ) : ( 457 <FeedSourceCard 458 feedUri={feedUri} 459 style={[a.pr_sm]} 460 showMinimalPlaceholder 461 hideTopBorder={true} 462 /> 463 )} 464 <View style={[a.pr_sm, a.flex_row, a.align_center, a.gap_sm]}> 465 <Button 466 testID={`feed-${feed.type}-togglePin`} 467 label={_(msg`Unpin feed`)} 468 onPress={onTogglePinned} 469 size="small" 470 color="primary_subtle" 471 shape="square"> 472 <ButtonIcon icon={PinIcon} /> 473 </Button> 474 {onMoveUp !== undefined ? ( 475 <> 476 <Button 477 testID={`feed-${feed.type}-moveUp`} 478 label={_(msg`Move feed up`)} 479 onPress={onMoveUp} 480 disabled={index === 0} 481 size="small" 482 color="secondary" 483 shape="square"> 484 <ButtonIcon icon={ArrowUpIcon} /> 485 </Button> 486 <Button 487 testID={`feed-${feed.type}-moveDown`} 488 label={_(msg`Move feed down`)} 489 onPress={onMoveDown} 490 disabled={index === total! - 1} 491 size="small" 492 color="secondary" 493 shape="square"> 494 <ButtonIcon icon={ArrowDownIcon} /> 495 </Button> 496 </> 497 ) : ( 498 dragHandle 499 )} 500 </View> 501 </View> 502 ) 503} 504 505function UnpinnedFeedItem({ 506 feed, 507 currentFeeds, 508 setCurrentFeeds, 509}: { 510 feed: AppBskyActorDefs.SavedFeed 511 currentFeeds: AppBskyActorDefs.SavedFeed[] 512 setCurrentFeeds: React.Dispatch< 513 React.SetStateAction<AppBskyActorDefs.SavedFeed[]> 514 > 515}) { 516 const {_} = useLingui() 517 const t = useTheme() 518 const playHaptic = useHaptics() 519 const feedUri = feed.value 520 521 const onTogglePinned = () => { 522 playHaptic() 523 setCurrentFeeds( 524 currentFeeds.map(f => 525 f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, 526 ), 527 ) 528 } 529 530 const onPressRemove = () => { 531 playHaptic() 532 setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) 533 } 534 535 return ( 536 <View style={[a.flex_row, a.border_b, t.atoms.border_contrast_low]}> 537 {feed.type === 'timeline' ? ( 538 <FollowingFeedCard /> 539 ) : ( 540 <FeedSourceCard 541 feedUri={feedUri} 542 showMinimalPlaceholder 543 hideTopBorder={true} 544 /> 545 )} 546 <View style={[a.pr_lg, a.flex_row, a.align_center, a.gap_sm]}> 547 <Button 548 testID={`feed-${feedUri}-toggleSave`} 549 label={_(msg`Remove from my feeds`)} 550 onPress={onPressRemove} 551 size="small" 552 color="secondary" 553 variant="ghost" 554 shape="square"> 555 <ButtonIcon icon={TrashIcon} /> 556 </Button> 557 <Button 558 testID={`feed-${feed.type}-togglePin`} 559 label={_(msg`Pin feed`)} 560 onPress={onTogglePinned} 561 size="small" 562 color="secondary" 563 shape="square"> 564 <ButtonIcon icon={PinIcon} /> 565 </Button> 566 </View> 567 </View> 568 ) 569} 570 571function SectionHeaderText({children}: {children: React.ReactNode}) { 572 const t = useTheme() 573 // eslint-disable-next-line bsky-internal/avoid-unwrapped-text 574 return ( 575 <View 576 style={[ 577 a.flex_row, 578 a.flex_1, 579 a.px_lg, 580 a.pt_2xl, 581 a.pb_md, 582 a.border_b, 583 t.atoms.border_contrast_low, 584 ]}> 585 <Text style={[a.text_xl, a.font_bold, a.leading_snug]}>{children}</Text> 586 </View> 587 ) 588} 589 590function FollowingFeedCard() { 591 const t = useTheme() 592 return ( 593 <View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}> 594 <View 595 style={[ 596 a.align_center, 597 a.justify_center, 598 a.rounded_sm, 599 a.mr_md, 600 { 601 width: 36, 602 height: 36, 603 backgroundColor: t.palette.primary_500, 604 }, 605 ]}> 606 <FilterTimeline 607 style={[ 608 { 609 width: 22, 610 height: 22, 611 }, 612 ]} 613 fill={t.palette.white} 614 /> 615 </View> 616 <View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}> 617 <Text style={[a.text_sm, a.font_semi_bold, a.leading_snug]}> 618 <Trans context="feed-name">Following</Trans> 619 </Text> 620 </View> 621 </View> 622 ) 623}