import {useCallback, useState} from 'react' import {View} from 'react-native' import type Animated from 'react-native-reanimated' import {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated' import {type AppBskyActorDefs} from '@atproto/api' import {TID} from '@atproto/common-web' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {RECOMMENDED_SAVED_FEEDS, TIMELINE_SAVED_FEED} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' import {logger} from '#/logger' import {useA11y} from '#/state/a11y' import { useOverwriteSavedFeedsMutation, usePreferencesQuery, } from '#/state/queries/preferences' import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useSetMinimalShellMode} from '#/state/shell' import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' import * as Toast from '#/view/com/util/Toast' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {SortableList} from '#/components/DraggableList' import { ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon, ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon, } from '#/components/icons/Arrow' import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' import {Pin_Filled_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import * as Layout from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' type Props = NativeStackScreenProps export function SavedFeeds({}: Props) { const {data: preferences} = usePreferencesQuery() const {screenReaderEnabled} = useA11y() if (!preferences) { return } if (screenReaderEnabled) { return } return } function SavedFeedsInner({ preferences, }: { preferences: UsePreferencesQueryResponse }) { const t = useTheme() const {_} = useLingui() const {gtMobile} = useBreakpoints() const setMinimalShellMode = useSetMinimalShellMode() const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = useOverwriteSavedFeedsMutation() const navigation = useNavigation() const scrollRef = useAnimatedRef() const scrollOffset = useScrollViewOffset(scrollRef) /* * Use optimistic data if exists and no error, otherwise fallback to remote * data */ const [currentFeeds, setCurrentFeeds] = useState( () => preferences.savedFeeds || [], ) const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds const pinnedFeeds = currentFeeds.filter(f => f.pinned) const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 const noFollowingFeed = currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType const [isDragging, setIsDragging] = useState(false) useFocusEffect( useCallback(() => { setMinimalShellMode(false) }, [setMinimalShellMode]), ) const onSaveChanges = async () => { try { await overwriteSavedFeeds(currentFeeds) Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) if (navigation.canGoBack()) { navigation.goBack() } else { navigation.navigate('Feeds') } } catch (e) { Toast.show(_(msg`There was an issue contacting the server`), 'xmark') logger.error('Failed to toggle pinned feed', {message: e}) } } return ( Feeds {noSavedFeedsOfAnyType && ( setCurrentFeeds( RECOMMENDED_SAVED_FEEDS.map(f => ({ ...f, id: TID.nextStr(), })), ) } /> )} Pinned Feeds {preferences ? ( !pinnedFeeds.length ? ( You don't have any pinned feeds. ) : ( f.id} itemHeight={68} scrollRef={scrollRef} scrollOffset={scrollOffset} onDragStart={() => setIsDragging(true)} onDragEnd={() => setIsDragging(false)} onReorder={reordered => { setCurrentFeeds([...reordered, ...unpinnedFeeds]) }} renderItem={(feed, dragHandle) => ( )} /> ) ) : ( )} {noFollowingFeed && ( setCurrentFeeds(feeds => [ ...feeds, {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, ]) } /> )} Saved Feeds {preferences ? ( !unpinnedFeeds.length ? ( You don't have any saved feeds. ) : ( unpinnedFeeds.map(f => ( )) ) ) : ( )} Feeds are custom algorithms that users build with a little coding expertise.{' '} See this guide {' '} for more information. ) } function SavedFeedsA11y({ preferences, }: { preferences: UsePreferencesQueryResponse }) { const t = useTheme() const {_} = useLingui() const {gtMobile} = useBreakpoints() const setMinimalShellMode = useSetMinimalShellMode() const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = useOverwriteSavedFeedsMutation() const navigation = useNavigation() const [currentFeeds, setCurrentFeeds] = useState( () => preferences.savedFeeds || [], ) const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds const pinnedFeeds = currentFeeds.filter(f => f.pinned) const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 const noFollowingFeed = currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType useFocusEffect( useCallback(() => { setMinimalShellMode(false) }, [setMinimalShellMode]), ) const onSaveChanges = async () => { try { await overwriteSavedFeeds(currentFeeds) Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) if (navigation.canGoBack()) { navigation.goBack() } else { navigation.navigate('Feeds') } } catch (e) { Toast.show(_(msg`There was an issue contacting the server`), 'xmark') logger.error('Failed to toggle pinned feed', {message: e}) } } const onMoveUp = (index: number) => { const pinned = [...pinnedFeeds] ;[pinned[index - 1], pinned[index]] = [pinned[index], pinned[index - 1]] setCurrentFeeds([...pinned, ...unpinnedFeeds]) } const onMoveDown = (index: number) => { const pinned = [...pinnedFeeds] ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] setCurrentFeeds([...pinned, ...unpinnedFeeds]) } return ( Feeds {noSavedFeedsOfAnyType && ( setCurrentFeeds( RECOMMENDED_SAVED_FEEDS.map(f => ({ ...f, id: TID.nextStr(), })), ) } /> )} Pinned Feeds {!pinnedFeeds.length ? ( You don't have any pinned feeds. ) : ( pinnedFeeds.map((feed, i) => ( onMoveUp(i)} onMoveDown={() => onMoveDown(i)} /> )) )} {noFollowingFeed && ( setCurrentFeeds(feeds => [ ...feeds, {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, ]) } /> )} Saved Feeds {!unpinnedFeeds.length ? ( You don't have any saved feeds. ) : ( unpinnedFeeds.map(f => ( )) )} Feeds are custom algorithms that users build with a little coding expertise.{' '} See this guide {' '} for more information. ) } function PinnedFeedItem({ feed, currentFeeds, setCurrentFeeds, dragHandle, index, total, onMoveUp, onMoveDown, }: { feed: AppBskyActorDefs.SavedFeed currentFeeds: AppBskyActorDefs.SavedFeed[] setCurrentFeeds: React.Dispatch< React.SetStateAction > dragHandle?: React.ReactNode index?: number total?: number onMoveUp?: () => void onMoveDown?: () => void }) { const {_} = useLingui() const t = useTheme() const playHaptic = useHaptics() const feedUri = feed.value const onTogglePinned = () => { playHaptic() setCurrentFeeds( currentFeeds.map(f => f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, ), ) } return ( {feed.type === 'timeline' ? ( ) : ( )} {onMoveUp !== undefined ? ( <> ) : ( dragHandle )} ) } function UnpinnedFeedItem({ feed, currentFeeds, setCurrentFeeds, }: { feed: AppBskyActorDefs.SavedFeed currentFeeds: AppBskyActorDefs.SavedFeed[] setCurrentFeeds: React.Dispatch< React.SetStateAction > }) { const {_} = useLingui() const t = useTheme() const playHaptic = useHaptics() const feedUri = feed.value const onTogglePinned = () => { playHaptic() setCurrentFeeds( currentFeeds.map(f => f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, ), ) } const onPressRemove = () => { playHaptic() setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) } return ( {feed.type === 'timeline' ? ( ) : ( )} ) } function SectionHeaderText({children}: {children: React.ReactNode}) { const t = useTheme() // eslint-disable-next-line bsky-internal/avoid-unwrapped-text return ( {children} ) } function FollowingFeedCard() { const t = useTheme() return ( Following ) }