From 372e20efa36c52a04cf2aa48816b82a1d96e0711 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 9 Sep 2025 22:40:25 +0300 Subject: ALF saved feeds screen (#8844) --- src/screens/SavedFeeds.tsx | 415 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 src/screens/SavedFeeds.tsx (limited to 'src/screens/SavedFeeds.tsx') diff --git a/src/screens/SavedFeeds.tsx b/src/screens/SavedFeeds.tsx new file mode 100644 index 000000000..1baceb4f4 --- /dev/null +++ b/src/screens/SavedFeeds.tsx @@ -0,0 +1,415 @@ +import {useCallback, useState} from 'react' +import {View} from 'react-native' +import Animated, {LinearTransition} from 'react-native-reanimated' +import {type AppBskyActorDefs} from '@atproto/api' +import {TID} from '@atproto/common-web' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' +import {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 { + 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 { + 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() + if (!preferences) { + 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() + + /* + * 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 + + 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. + + + ) : ( + pinnedFeeds.map(f => ( + + )) + ) + ) : ( + + + + )} + + {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 ListItem({ + feed, + isPinned, + currentFeeds, + setCurrentFeeds, +}: { + feed: AppBskyActorDefs.SavedFeed + isPinned: boolean + currentFeeds: AppBskyActorDefs.SavedFeed[] + setCurrentFeeds: React.Dispatch + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const t = useTheme() + const playHaptic = useHaptics() + const feedUri = feed.value + + const onTogglePinned = async () => { + playHaptic() + setCurrentFeeds( + currentFeeds.map(f => + f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, + ), + ) + } + + const onPressUp = async () => { + if (!isPinned) return + + const nextFeeds = currentFeeds.slice() + const ids = currentFeeds.map(f => f.id) + const index = ids.indexOf(feed.id) + const nextIndex = index - 1 + + if (index === -1 || index === 0) return + ;[nextFeeds[index], nextFeeds[nextIndex]] = [ + nextFeeds[nextIndex], + nextFeeds[index], + ] + + setCurrentFeeds(nextFeeds) + } + + const onPressDown = async () => { + if (!isPinned) return + + const nextFeeds = currentFeeds.slice() + const ids = currentFeeds.map(f => f.id) + const index = ids.indexOf(feed.id) + const nextIndex = index + 1 + + if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1) + return + ;[nextFeeds[index], nextFeeds[nextIndex]] = [ + nextFeeds[nextIndex], + nextFeeds[index], + ] + + setCurrentFeeds(nextFeeds) + } + + const onPressRemove = async () => { + playHaptic() + setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) + } + + return ( + + {feed.type === 'timeline' ? ( + + ) : ( + + )} + + {isPinned ? ( + <> + + + + ) : ( + + )} + + + + ) +} + +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 + + + + ) +} -- cgit 1.4.1