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