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/Navigation.tsx | 2 +- src/screens/Feeds/NoFollowingFeed.tsx | 34 +-- src/screens/Feeds/NoSavedFeedsOfAnyType.tsx | 17 +- src/screens/SavedFeeds.tsx | 415 +++++++++++++++++++++++++ src/view/screens/SavedFeeds.tsx | 450 ---------------------------- 5 files changed, 442 insertions(+), 476 deletions(-) create mode 100644 src/screens/SavedFeeds.tsx delete mode 100644 src/view/screens/SavedFeeds.tsx diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 45f078625..fa33a9d56 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -64,7 +64,6 @@ import {PostThreadScreen} from '#/view/screens/PostThread' import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' import {ProfileScreen} from '#/view/screens/Profile' import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' -import {SavedFeeds} from '#/view/screens/SavedFeeds' import {Storybook} from '#/view/screens/Storybook' import {SupportScreen} from '#/view/screens/Support' import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' @@ -92,6 +91,7 @@ import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch' import {ProfileListScreen} from '#/screens/ProfileList' +import {SavedFeeds} from '#/screens/SavedFeeds' import {SearchScreen} from '#/screens/Search' import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' diff --git a/src/screens/Feeds/NoFollowingFeed.tsx b/src/screens/Feeds/NoFollowingFeed.tsx index fa48cca72..60205b856 100644 --- a/src/screens/Feeds/NoFollowingFeed.tsx +++ b/src/screens/Feeds/NoFollowingFeed.tsx @@ -1,5 +1,4 @@ -import React from 'react' -import {View} from 'react-native' +import {type GestureResponderEvent, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -9,27 +8,26 @@ import {atoms as a, useTheme} from '#/alf' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' -export function NoFollowingFeed() { +export function NoFollowingFeed({onAddFeed}: {onAddFeed?: () => void}) { const t = useTheme() const {_} = useLingui() const {mutateAsync: addSavedFeeds} = useAddSavedFeedsMutation() - const addRecommendedFeeds = React.useCallback( - (e: any) => { - e.preventDefault() + const addRecommendedFeeds = (e: GestureResponderEvent) => { + e.preventDefault() - addSavedFeeds([ - { - ...TIMELINE_SAVED_FEED, - pinned: true, - }, - ]) + addSavedFeeds([ + { + ...TIMELINE_SAVED_FEED, + pinned: true, + }, + ]) - // prevent navigation - return false - }, - [addSavedFeeds], - ) + onAddFeed?.() + + // prevent navigation + return false as const + } return ( @@ -37,7 +35,7 @@ export function NoFollowingFeed() { Looks like you're missing a following feed.{' '} diff --git a/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx b/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx index 8f6bd9d2e..db0ed1a7d 100644 --- a/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx +++ b/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {TID} from '@atproto/common-web' import {msg, Trans} from '@lingui/macro' @@ -16,20 +15,25 @@ import {Text} from '#/components/Typography' * feeds if pressed. It should only be presented to the user if they actually * have no other feeds saved. */ -export function NoSavedFeedsOfAnyType() { +export function NoSavedFeedsOfAnyType({ + onAddRecommendedFeeds, +}: { + onAddRecommendedFeeds?: () => void +}) { const t = useTheme() const {_} = useLingui() const {isPending, mutateAsync: overwriteSavedFeeds} = useOverwriteSavedFeedsMutation() - const addRecommendedFeeds = React.useCallback(async () => { + const addRecommendedFeeds = async () => { + onAddRecommendedFeeds?.() await overwriteSavedFeeds( RECOMMENDED_SAVED_FEEDS.map(f => ({ ...f, id: TID.nextStr(), })), ) - }, [overwriteSavedFeeds]) + } return ( - + {_(msg`Use recommended`)} 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 + + + + ) +} diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx deleted file mode 100644 index 0e85bdf73..000000000 --- a/src/view/screens/SavedFeeds.tsx +++ /dev/null @@ -1,450 +0,0 @@ -import React from 'react' -import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' -import Animated, {LinearTransition} from 'react-native-reanimated' -import {type AppBskyActorDefs} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -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 {useHaptics} from '#/lib/haptics' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import { - type CommonNavigatorParams, - type NavigationProp, -} from '#/lib/routes/types' -import {colors, s} from '#/lib/styles' -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 {TextLink} from '#/view/com/util/Link' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' -import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' -import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' -import * as Layout from '#/components/Layout' -import {Loader} from '#/components/Loader' -import {Text as NewText} from '#/components/Typography' - -type Props = NativeStackScreenProps -export function SavedFeeds({}: Props) { - const {data: preferences} = usePreferencesQuery() - if (!preferences) { - return - } - return -} - -function SavedFeedsInner({ - preferences, -}: { - preferences: UsePreferencesQueryResponse -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {isMobile, isDesktop} = useWebMediaQueries() - 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] = React.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( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const onSaveChanges = React.useCallback(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}) - } - }, [_, overwriteSavedFeeds, currentFeeds, navigation]) - - return ( - - - - - - Feeds - - - - - - - {noSavedFeedsOfAnyType && ( - - - - )} - - - - Pinned Feeds - - - - {preferences ? ( - !pinnedFeeds.length ? ( - - - You don't have any pinned feeds. - - - ) : ( - pinnedFeeds.map(f => ( - - )) - ) - ) : ( - - )} - - {noFollowingFeed && ( - - - - )} - - - - 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.{' '} - {' '} - 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 pal = usePalette('default') - const playHaptic = useHaptics() - const feedUri = feed.value - - const onTogglePinned = React.useCallback(async () => { - playHaptic() - setCurrentFeeds( - currentFeeds.map(f => - f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, - ), - ) - }, [playHaptic, feed, currentFeeds, setCurrentFeeds]) - - const onPressUp = React.useCallback(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) - }, [feed, isPinned, setCurrentFeeds, currentFeeds]) - - const onPressDown = React.useCallback(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) - }, [feed, isPinned, setCurrentFeeds, currentFeeds]) - - const onPressRemove = React.useCallback(async () => { - playHaptic() - setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) - }, [playHaptic, feed, currentFeeds, setCurrentFeeds]) - - return ( - - {feed.type === 'timeline' ? ( - - ) : ( - - )} - {isPinned ? ( - <> - ({ - backgroundColor: pal.viewLight.backgroundColor, - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 4, - marginRight: 8, - opacity: state.hovered || state.pressed ? 0.5 : 1, - })} - testID={`feed-${feed.type}-moveUp`}> - - - ({ - backgroundColor: pal.viewLight.backgroundColor, - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 4, - marginRight: 8, - opacity: state.hovered || state.pressed ? 0.5 : 1, - })} - testID={`feed-${feed.type}-moveDown`}> - - - - ) : ( - ({ - marginRight: 8, - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 4, - opacity: state.hovered || state.focused ? 0.5 : 1, - })}> - - - )} - - ({ - backgroundColor: pal.viewLight.backgroundColor, - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 4, - opacity: state.hovered || state.focused ? 0.5 : 1, - })} - testID={`feed-${feed.type}-togglePin`}> - - - - - ) -} - -function FollowingFeedCard() { - const t = useTheme() - return ( - - - - - - - Following - - - - ) -} - -const styles = StyleSheet.create({ - empty: { - paddingHorizontal: 20, - paddingVertical: 20, - borderRadius: 8, - marginHorizontal: 10, - marginTop: 10, - }, - title: { - paddingHorizontal: 14, - paddingTop: 20, - paddingBottom: 10, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - itemContainer: { - flexDirection: 'row', - alignItems: 'center', - borderBottomWidth: StyleSheet.hairlineWidth, - }, - footerText: { - paddingHorizontal: 26, - paddingVertical: 22, - }, -}) -- cgit 1.4.1