Bluesky app fork with some witchin' additions 💫

Movable following feed (#3593)

* Handle home algo with backwards compat

* Remove todo, fix pwi view

* Simplify filter logic

* Handle edge case

* Handle home algo in FeedSourceCard

* Fix handling of pinned feed if home algo is disabled

* Handle home algo on ProfileFeed screen

* Rename

* Fix pinned feeds key

* Improve perf of pinned feeds with primary algo

* Update statsig API

* Revert unneeded changes

* Support following feed as well

* Better formatting

* Clarify primary algo usage

* Better comment

* Handle saved feed screen edge case

* Restore Feeds sparkle, fix line height

* Move gate call down

* Filter out primary algo from feeds page

* Filter dupe from Feeds screen

* Simplify logic

* Missing following handling

* Hide primary feed setting outside exp

* Revert testing change

* Migrate usePinnedFeedInfos

* Migrate FeedSourceCard

* Migrate Feeds screen

* Migrate SavedFeeds screen

* Handle timeline in feed infos

* Finish migrating ProfileFeed, FeedSourceCard

* Migrate ProfileList

* Finalize mutation hooks

* Allow unsaving lists

* Handle following feed on Feeds screen

* Handle following on SavedFeeds

* Get rid of deprecated interface usages

* Handle no pinned feeds

* Handle no feeds on Feeds screen

* Reuse component on SavedFeeds screen

* Handle no following feed

* Remove primary algo references

* Migrate to new plural APIs

* Remove unused event

* Prevent duplicate keys

* Make handling much more clear

* Dedupe useHeaderOffset

* Filter unknown feed types at source

* Use just following

* Immprove key handling

* Resume from last tab

* Bump sdk

* Revert Gemfile

* Additional protection in FeedSourceCard

* Fix ProfileList save/unsave handling

* Translate

* Translate

* Match existing handling post-signup

* Ensure onboarding results in correct selected feeds

* Some testing tweaks on create/onboarding

* Revert primary algo consderations

* Remove comment

* Handle default feed setting

* Rm unnecessary type cast

* Remove premature gate check

* Remove nullable check in onPageSelecting, assume the pager checks bounds

* Use null for default selected feed

* Rm unrelated change

* Remove the concept of __key__

I don't think this concept is consistent.

It's introduced on FeedSourceInfo which is used both by pinned feeds and by useFeedSourceInfoQuery. Pinned feeds use the pinning ID there. But there is no pinning ID for useFeedSourceInfoQuery. So this means this field is sometimes one thing and sometimes some other thing. That is a decent sign that it shouldn't be on that type at all.

It's not used anywhere except the desktop feed enumeration. It seems reasonable to assume there that we wouldn't want to show the same feed URL twice. (And if it does occur in the array twice, IMO we should solve that at the API level and dedupe it on read or next write.) So I think we should just use the URL in that place. (I used the descriptor, which is equivalent.)

* Dedupe pinned feeds by URL on read

* Filter timeline out of mergefeed sources

* Put FeedDescriptor into FeedSourceInfo

* Group saved info with feed for pins

This removes a loop within a loop within a loop.

* Fix Feeds link on native

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Eric Bailey
Dan Abramov
and committed by
GitHub
08979f37 2974ce1b

+1131 -550
+1 -1
modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx
··· 1 - import {requireNativeViewManager} from 'expo-modules-core' 2 1 import * as React from 'react' 2 + import {requireNativeViewManager} from 'expo-modules-core' 3 3 4 4 import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' 5 5
+1 -1
package.json
··· 51 51 }, 52 52 "dependencies": { 53 53 "@atproto-labs/api": "^0.12.8-clipclops.0", 54 - "@atproto/api": "^0.12.5", 54 + "@atproto/api": "^0.12.6", 55 55 "@bam.tech/react-native-image-resizer": "^3.0.4", 56 56 "@braintree/sanitize-url": "^6.0.2", 57 57 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+5 -5
src/components/LabelingServiceCard/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import {AppBskyLabelerDefs} from '@atproto/api' 3 4 import {msg, Plural, Trans} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 - import {AppBskyLabelerDefs} from '@atproto/api' 6 6 7 7 import {getLabelingServiceTitle} from '#/lib/moderation' 8 - import {Link as InternalLink, LinkProps} from '#/components/Link' 9 - import {Text} from '#/components/Typography' 8 + import {sanitizeHandle} from '#/lib/strings/handles' 10 9 import {useLabelerInfoQuery} from '#/state/queries/labeler' 10 + import {UserAvatar} from '#/view/com/util/UserAvatar' 11 11 import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 12 + import {Link as InternalLink, LinkProps} from '#/components/Link' 12 13 import {RichText} from '#/components/RichText' 14 + import {Text} from '#/components/Typography' 13 15 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron' 14 - import {UserAvatar} from '#/view/com/util/UserAvatar' 15 - import {sanitizeHandle} from '#/lib/strings/handles' 16 16 17 17 type LabelingServiceProps = { 18 18 labeler: AppBskyLabelerDefs.LabelerViewDetailed
+16
src/components/hooks/useHeaderOffset.ts
··· 1 + import {useWindowDimensions} from 'react-native' 2 + 3 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 4 + 5 + export function useHeaderOffset() { 6 + const {isDesktop, isTablet} = useWebMediaQueries() 7 + const {fontScale} = useWindowDimensions() 8 + if (isDesktop || isTablet) { 9 + return 0 10 + } 11 + const navBarHeight = 42 12 + const tabBarPad = 10 + 10 + 3 // padding + border 13 + const normalLineHeight = 1.2 14 + const tabBarText = 16 * normalLineHeight * fontScale 15 + return navBarHeight + tabBarPad + tabBarText 16 + }
+9
src/components/icons/Home.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Home_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M11.46 1.362a2 2 0 0 1 1.08 0c.249.07.448.188.611.301.146.102.306.232.467.363l6.421 5.218.046.036c.169.137.38.308.54.53a2 2 0 0 1 .304.64c.073.264.072.536.071.753v9.229c0 .252 0 .498-.017.706a2.023 2.023 0 0 1-.201.77 2 2 0 0 1-.874.874 2.02 2.02 0 0 1-.77.201c-.208.017-.454.017-.706.017H5.568c-.252 0-.498 0-.706-.017a2.02 2.02 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C3 18.93 3 18.684 3 18.432V9.203c0-.217-.002-.49.07-.754a2 2 0 0 1 .304-.638c.16-.223.372-.394.541-.53l.045-.037 6.422-5.218c.161-.13.321-.26.467-.362.163-.114.362-.232.612-.302Zm.532 1.943c-.077.054-.18.136-.37.29l-6.4 5.2a6.315 6.315 0 0 0-.215.18c-.002 0-.003.002-.004.003v.004C5 9.036 5 9.112 5 9.262V18.4a8.18 8.18 0 0 0 .011.588l.014.002c.116.01.278.01.575.01H8v-5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v5h2.4a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V9.262c0-.15 0-.226-.003-.28v-.004l-.003-.003a6.448 6.448 0 0 0-.216-.18l-6.4-5.2a7.373 7.373 0 0 0-.37-.29L12 3.299l-.008.006ZM14 19v-5h-4v5h4Z', 5 + }) 6 + 7 + export const Home_Filled_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M13.261 1.736a2 2 0 0 0-2.522 0l-7 5.687A2 2 0 0 0 3 8.976V19a2 2 0 0 0 2 2h3v-8a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v8h3a2 2 0 0 0 2-2V8.976a2 2 0 0 0-.739-1.553l-7-5.687ZM14 21h-4v-7h4v7Z', 9 + })
+2 -2
src/components/moderation/LabelsOnMe.tsx
··· 3 3 import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api' 4 4 import {msg, Plural} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 - import {useSession} from '#/state/session' 7 6 7 + import {useSession} from '#/state/session' 8 8 import {atoms as a} from '#/alf' 9 - import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button' 9 + import {Button, ButtonIcon, ButtonSize, ButtonText} from '#/components/Button' 10 10 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 11 11 import { 12 12 LabelsOnMeDialog,
+20 -1
src/lib/constants.ts
··· 1 1 import {Insets, Platform} from 'react-native' 2 + import {AppBskyActorDefs} from '@atproto/api' 2 3 3 4 export const LOCAL_DEV_SERVICE = 4 5 Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' ··· 44 45 } 45 46 46 47 export function IS_PROD_SERVICE(url?: string) { 47 - return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE 48 + return url && url !== STAGING_SERVICE && !url.startsWith(LOCAL_DEV_SERVICE) 48 49 } 49 50 50 51 export const PROD_DEFAULT_FEED = (rkey: string) => ··· 91 92 'did:plc:vpkhqolt662uhesyj6nxm7ys', 92 93 'did:plc:q6gjnaw2blty4crticxkmujt', 93 94 ] 95 + 96 + export const DISCOVER_FEED_URI = 97 + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' 98 + export const DISCOVER_SAVED_FEED = { 99 + type: 'feed', 100 + value: DISCOVER_FEED_URI, 101 + pinned: true, 102 + } 103 + export const TIMELINE_SAVED_FEED = { 104 + type: 'timeline', 105 + value: 'following', 106 + pinned: true, 107 + } 108 + 109 + export const RECOMMENDED_SAVED_FEEDS: Pick< 110 + AppBskyActorDefs.SavedFeed, 111 + 'type' | 'value' | 'pinned' 112 + >[] = [DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED] 94 113 95 114 export const GIF_SERVICE = 'https://gifs.bsky.app' 96 115
+50
src/screens/Feeds/NoFollowingFeed.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {TIMELINE_SAVED_FEED} from '#/lib/constants' 7 + import {useAddSavedFeedsMutation} from '#/state/queries/preferences' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {InlineLinkText} from '#/components/Link' 10 + import {Text} from '#/components/Typography' 11 + 12 + export function NoFollowingFeed() { 13 + const t = useTheme() 14 + const {_} = useLingui() 15 + const {mutateAsync: addSavedFeeds} = useAddSavedFeedsMutation() 16 + 17 + const addRecommendedFeeds = React.useCallback( 18 + (e: any) => { 19 + e.preventDefault() 20 + 21 + addSavedFeeds([ 22 + { 23 + ...TIMELINE_SAVED_FEED, 24 + pinned: true, 25 + }, 26 + ]) 27 + 28 + // prevent navigation 29 + return false 30 + }, 31 + [addSavedFeeds], 32 + ) 33 + 34 + return ( 35 + <View style={[a.flex_row, a.flex_wrap, a.align_center, a.py_md, a.px_lg]}> 36 + <Text 37 + style={[a.leading_snug, t.atoms.text_contrast_medium, {maxWidth: 310}]}> 38 + <Trans>Looks like you're missing a following feed.</Trans>{' '} 39 + </Text> 40 + 41 + <InlineLinkText 42 + to="/" 43 + label={_(msg`Add the default feed of only people you follow`)} 44 + onPress={addRecommendedFeeds} 45 + style={[a.leading_snug]}> 46 + <Trans>Click here to add one.</Trans> 47 + </InlineLinkText> 48 + </View> 49 + ) 50 + }
+57
src/screens/Feeds/NoSavedFeedsOfAnyType.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {TID} from '@atproto/common-web' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {RECOMMENDED_SAVED_FEEDS} from '#/lib/constants' 8 + import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences' 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 11 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 12 + import {Text} from '#/components/Typography' 13 + 14 + /** 15 + * Explicitly named, since the CTA in this component will overwrite all saved 16 + * feeds if pressed. It should only be presented to the user if they actually 17 + * have no other feeds saved. 18 + */ 19 + export function NoSavedFeedsOfAnyType() { 20 + const t = useTheme() 21 + const {_} = useLingui() 22 + const {isPending, mutateAsync: overwriteSavedFeeds} = 23 + useOverwriteSavedFeedsMutation() 24 + 25 + const addRecommendedFeeds = React.useCallback(async () => { 26 + await overwriteSavedFeeds( 27 + RECOMMENDED_SAVED_FEEDS.map(f => ({ 28 + ...f, 29 + id: TID.nextStr(), 30 + })), 31 + ) 32 + }, [overwriteSavedFeeds]) 33 + 34 + return ( 35 + <View 36 + style={[a.flex_row, a.flex_wrap, a.justify_between, a.p_xl, a.gap_md]}> 37 + <Text 38 + style={[a.leading_snug, t.atoms.text_contrast_medium, {maxWidth: 310}]}> 39 + <Trans> 40 + Looks like you haven't saved any feeds! Use our recommendations or 41 + browse more below. 42 + </Trans> 43 + </Text> 44 + 45 + <Button 46 + disabled={isPending} 47 + label={_(msg`Apply default recommended feeds`)} 48 + size="small" 49 + variant="solid" 50 + color="primary" 51 + onPress={addRecommendedFeeds}> 52 + <ButtonIcon icon={Plus} position="left" /> 53 + <ButtonText>{_(msg`Use recommended`)}</ButtonText> 54 + </Button> 55 + </View> 56 + ) 57 + }
+129
src/screens/Home/NoFeedsPinned.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {TID} from '@atproto/common-web' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useNavigation} from '@react-navigation/native' 7 + 8 + import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants' 9 + import {isNative} from '#/platform/detection' 10 + import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences' 11 + import {UsePreferencesQueryResponse} from '#/state/queries/preferences' 12 + import {NavigationProp} from 'lib/routes/types' 13 + import {CenteredView} from '#/view/com/util/Views' 14 + import {atoms as a} from '#/alf' 15 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 16 + import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' 17 + import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 18 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 19 + import {Link} from '#/components/Link' 20 + import {Text} from '#/components/Typography' 21 + 22 + export function NoFeedsPinned({ 23 + preferences, 24 + }: { 25 + preferences: UsePreferencesQueryResponse 26 + }) { 27 + const {_} = useLingui() 28 + const headerOffset = useHeaderOffset() 29 + const navigation = useNavigation<NavigationProp>() 30 + const {isPending, mutateAsync: overwriteSavedFeeds} = 31 + useOverwriteSavedFeedsMutation() 32 + 33 + const addRecommendedFeeds = React.useCallback(async () => { 34 + let skippedTimeline = false 35 + let skippedDiscover = false 36 + let remainingSavedFeeds = [] 37 + 38 + // remove first instance of both timeline and discover, since we're going to overwrite them 39 + for (const savedFeed of preferences.savedFeeds) { 40 + if (savedFeed.type === 'timeline' && !skippedTimeline) { 41 + skippedTimeline = true 42 + } else if ( 43 + savedFeed.value === DISCOVER_SAVED_FEED.value && 44 + !skippedDiscover 45 + ) { 46 + skippedDiscover = true 47 + } else { 48 + remainingSavedFeeds.push(savedFeed) 49 + } 50 + } 51 + 52 + const toSave = [ 53 + { 54 + ...DISCOVER_SAVED_FEED, 55 + pinned: true, 56 + id: TID.nextStr(), 57 + }, 58 + { 59 + ...TIMELINE_SAVED_FEED, 60 + pinned: true, 61 + id: TID.nextStr(), 62 + }, 63 + ...remainingSavedFeeds, 64 + ] 65 + 66 + await overwriteSavedFeeds(toSave) 67 + }, [overwriteSavedFeeds, preferences.savedFeeds]) 68 + 69 + const onPressFeedsLink = React.useCallback(() => { 70 + if (isNative) { 71 + // Hack that's necessary due to how our navigators are set up. 72 + navigation.navigate('FeedsTab') 73 + navigation.popToTop() 74 + return false 75 + } 76 + }, [navigation]) 77 + 78 + return ( 79 + <CenteredView sideBorders style={[a.h_full_vh]}> 80 + <View 81 + style={[ 82 + a.align_center, 83 + a.h_full_vh, 84 + a.py_3xl, 85 + a.px_xl, 86 + { 87 + paddingTop: headerOffset + a.py_3xl.paddingTop, 88 + }, 89 + ]}> 90 + <View style={[a.align_center, a.gap_sm, a.pb_xl]}> 91 + <Text style={[a.text_xl, a.font_bold]}> 92 + <Trans>Whoops!</Trans> 93 + </Text> 94 + <Text 95 + style={[a.text_md, a.text_center, a.leading_snug, {maxWidth: 340}]}> 96 + <Trans> 97 + Looks like you unpinned all your feeds. But don't worry, you can 98 + add some below 😄 99 + </Trans> 100 + </Text> 101 + </View> 102 + 103 + <View style={[a.flex_row, a.gap_md, a.justify_center, a.flex_wrap]}> 104 + <Button 105 + disabled={isPending} 106 + label={_(msg`Apply default recommended feeds`)} 107 + size="medium" 108 + variant="solid" 109 + color="primary" 110 + onPress={addRecommendedFeeds}> 111 + <ButtonIcon icon={Plus} position="left" /> 112 + <ButtonText>{_(msg`Add recommended feeds`)}</ButtonText> 113 + </Button> 114 + 115 + <Link 116 + label={_(msg`Browse other feeds`)} 117 + to="/feeds" 118 + onPress={onPressFeedsLink} 119 + size="medium" 120 + variant="solid" 121 + color="secondary"> 122 + <ButtonIcon icon={ListSparkle} position="left" /> 123 + <ButtonText>{_(msg`Browse other feeds`)}</ButtonText> 124 + </Link> 125 + </View> 126 + </View> 127 + </CenteredView> 128 + ) 129 + }
+1 -1
src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
··· 2 2 import {View} from 'react-native' 3 3 import {Image} from 'expo-image' 4 4 import {LinearGradient} from 'expo-linear-gradient' 5 - import {Trans, msg} from '@lingui/macro' 5 + import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 8 import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
+49 -8
src/screens/Onboarding/StepFinished.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import {TID} from '@atproto/common-web' 3 4 import {msg, Trans} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 6 6 7 import {useAnalytics} from '#/lib/analytics/analytics' 7 - import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants' 8 + import {BSKY_APP_ACCOUNT_DID, IS_PROD_SERVICE} from '#/lib/constants' 9 + import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants' 8 10 import {logEvent} from '#/lib/statsig/statsig' 9 11 import {logger} from '#/logger' 10 - import {useSetSaveFeedsMutation} from '#/state/queries/preferences' 12 + import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences' 11 13 import {useAgent} from '#/state/session' 12 14 import {useOnboardingDispatch} from '#/state/shell' 13 15 import { ··· 37 39 const {state, dispatch} = React.useContext(Context) 38 40 const onboardDispatch = useOnboardingDispatch() 39 41 const [saving, setSaving] = React.useState(false) 40 - const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation() 42 + const {mutateAsync: overwriteSavedFeeds} = useOverwriteSavedFeedsMutation() 41 43 const {getAgent} = useAgent() 42 44 43 45 const finishOnboarding = React.useCallback(async () => { ··· 64 66 // these must be serial 65 67 (async () => { 66 68 await getAgent().setInterestsPref({tags: selectedInterests}) 67 - await saveFeeds({ 68 - saved: selectedFeeds, 69 - pinned: selectedFeeds, 70 - }) 69 + 70 + // TODO: In the reduced onboarding, we'll want to exit early here. 71 + 72 + const otherFeeds = selectedFeeds.length 73 + ? selectedFeeds.map(f => ({ 74 + type: 'feed', 75 + value: f, 76 + pinned: true, 77 + id: TID.nextStr(), 78 + })) 79 + : [] 80 + 81 + /* 82 + * If no selected feeds and we're in prod, add the discover feed 83 + * (mimics old behavior) 84 + */ 85 + if ( 86 + IS_PROD_SERVICE(getAgent().service.toString()) && 87 + !otherFeeds.length 88 + ) { 89 + otherFeeds.push({ 90 + ...DISCOVER_SAVED_FEED, 91 + pinned: true, 92 + id: TID.nextStr(), 93 + }) 94 + } 95 + 96 + await overwriteSavedFeeds([ 97 + { 98 + ...TIMELINE_SAVED_FEED, 99 + pinned: true, 100 + id: TID.nextStr(), 101 + }, 102 + ...otherFeeds, 103 + ]) 71 104 })(), 72 105 ]) 73 106 } catch (e: any) { ··· 82 115 track('OnboardingV2:StepFinished:End') 83 116 track('OnboardingV2:Complete') 84 117 logEvent('onboarding:finished:nextPressed', {}) 85 - }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track, getAgent]) 118 + }, [ 119 + state, 120 + dispatch, 121 + onboardDispatch, 122 + setSaving, 123 + overwriteSavedFeeds, 124 + track, 125 + getAgent, 126 + ]) 86 127 87 128 React.useEffect(() => { 88 129 track('OnboardingV2:StepFinished:Start')
+3 -2
src/state/preferences/feed-tuners.tsx
··· 1 1 import {useMemo} from 'react' 2 + 2 3 import {FeedTuner} from '#/lib/api/feed-manip' 3 4 import {FeedDescriptor} from '../queries/post-feed' 4 - import {useLanguagePrefs} from './languages' 5 5 import {usePreferencesQuery} from '../queries/preferences' 6 6 import {useSession} from '../session' 7 + import {useLanguagePrefs} from './languages' 7 8 8 9 export function useFeedTuners(feedDesc: FeedDescriptor) { 9 10 const langPrefs = useLanguagePrefs() ··· 20 21 if (feedDesc.startsWith('list')) { 21 22 return [FeedTuner.dedupReposts] 22 23 } 23 - if (feedDesc === 'home' || feedDesc === 'following') { 24 + if (feedDesc === 'following') { 24 25 const feedTuners = [] 25 26 26 27 if (preferences?.feedViewPrefs.hideReposts) {
+64 -37
src/state/queries/feed.ts
··· 1 1 import { 2 + AppBskyActorDefs, 2 3 AppBskyFeedDefs, 3 4 AppBskyGraphDefs, 4 5 AppBskyUnspeccedGetPopularFeedGenerators, ··· 13 14 useQuery, 14 15 } from '@tanstack/react-query' 15 16 17 + import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants' 16 18 import {sanitizeDisplayName} from '#/lib/strings/display-names' 17 19 import {sanitizeHandle} from '#/lib/strings/handles' 18 20 import {STALE} from '#/state/queries' 19 21 import {usePreferencesQuery} from '#/state/queries/preferences' 20 22 import {useAgent, useSession} from '#/state/session' 21 23 import {router} from '#/routes' 24 + import {FeedDescriptor} from './post-feed' 22 25 23 26 export type FeedSourceFeedInfo = { 24 27 type: 'feed' 25 28 uri: string 29 + feedDescriptor: FeedDescriptor 26 30 route: { 27 31 href: string 28 32 name: string ··· 41 45 export type FeedSourceListInfo = { 42 46 type: 'list' 43 47 uri: string 48 + feedDescriptor: FeedDescriptor 44 49 route: { 45 50 href: string 46 51 name: string ··· 79 84 return { 80 85 type: 'feed', 81 86 uri: view.uri, 87 + feedDescriptor: `feedgen|${view.uri}`, 82 88 cid: view.cid, 83 89 route: { 84 90 href, ··· 110 116 return { 111 117 type: 'list', 112 118 uri: view.uri, 119 + feedDescriptor: `list|${view.uri}`, 113 120 route: { 114 121 href, 115 122 name: route[0], ··· 202 209 }) 203 210 } 204 211 205 - const FOLLOWING_FEED_STUB: FeedSourceInfo = { 206 - type: 'feed', 207 - displayName: 'Following', 208 - uri: '', 209 - route: { 210 - href: '/', 211 - name: 'Home', 212 - params: {}, 213 - }, 214 - cid: '', 215 - avatar: '', 216 - description: new RichText({text: ''}), 217 - creatorDid: '', 218 - creatorHandle: '', 219 - likeCount: 0, 220 - likeUri: '', 212 + export type SavedFeedSourceInfo = FeedSourceInfo & { 213 + savedFeed: AppBskyActorDefs.SavedFeed 221 214 } 222 - const DISCOVER_FEED_STUB: FeedSourceInfo = { 215 + 216 + const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = { 223 217 type: 'feed', 224 218 displayName: 'Discover', 225 - uri: '', 219 + uri: DISCOVER_FEED_URI, 220 + feedDescriptor: `feedgen|${DISCOVER_FEED_URI}`, 226 221 route: { 227 222 href: '/', 228 223 name: 'Home', ··· 235 230 creatorHandle: '', 236 231 likeCount: 0, 237 232 likeUri: '', 233 + // --- 234 + savedFeed: { 235 + id: 'pwi-discover', 236 + ...DISCOVER_SAVED_FEED, 237 + }, 238 238 } 239 239 240 240 const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos' ··· 243 243 const {hasSession} = useSession() 244 244 const {getAgent} = useAgent() 245 245 const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() 246 - const pinnedUris = preferences?.feeds?.pinned ?? [] 246 + const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? [] 247 247 248 248 return useQuery({ 249 249 staleTime: STALE.INFINITY, 250 250 enabled: !isLoadingPrefs, 251 251 queryKey: [ 252 252 pinnedFeedInfosQueryKeyRoot, 253 - (hasSession ? 'authed:' : 'unauthed:') + pinnedUris.join(','), 253 + (hasSession ? 'authed:' : 'unauthed:') + 254 + pinnedItems.map(f => f.value).join(','), 254 255 ], 255 256 queryFn: async () => { 256 - let resolved = new Map() 257 + if (!hasSession) { 258 + return [PWI_DISCOVER_FEED_STUB] 259 + } 260 + 261 + let resolved = new Map<string, FeedSourceInfo>() 257 262 258 263 // Get all feeds. We can do this in a batch. 259 - const feedUris = pinnedUris.filter( 260 - uri => getFeedTypeFromUri(uri) === 'feed', 261 - ) 264 + const pinnedFeeds = pinnedItems.filter(feed => feed.type === 'feed') 262 265 let feedsPromise = Promise.resolve() 263 - if (feedUris.length > 0) { 266 + if (pinnedFeeds.length > 0) { 264 267 feedsPromise = getAgent() 265 268 .app.bsky.feed.getFeedGenerators({ 266 - feeds: feedUris, 269 + feeds: pinnedFeeds.map(f => f.value), 267 270 }) 268 271 .then(res => { 269 - for (let feedView of res.data.feeds) { 272 + for (let i = 0; i < res.data.feeds.length; i++) { 273 + const feedView = res.data.feeds[i] 270 274 resolved.set(feedView.uri, hydrateFeedGenerator(feedView)) 271 275 } 272 276 }) 273 277 } 274 278 275 279 // Get all lists. This currently has to be done individually. 276 - const listUris = pinnedUris.filter( 277 - uri => getFeedTypeFromUri(uri) === 'list', 278 - ) 279 - const listsPromises = listUris.map(listUri => 280 + const pinnedLists = pinnedItems.filter(feed => feed.type === 'list') 281 + const listsPromises = pinnedLists.map(list => 280 282 getAgent() 281 283 .app.bsky.graph.getList({ 282 - list: listUri, 284 + list: list.value, 283 285 limit: 1, 284 286 }) 285 287 .then(res => { ··· 288 290 }), 289 291 ) 290 292 291 - // The returned result will have the original order. 292 - const result = [hasSession ? FOLLOWING_FEED_STUB : DISCOVER_FEED_STUB] 293 293 await Promise.allSettled([feedsPromise, ...listsPromises]) 294 - for (let pinnedUri of pinnedUris) { 295 - if (resolved.has(pinnedUri)) { 296 - result.push(resolved.get(pinnedUri)) 294 + 295 + // order the feeds/lists in the order they were pinned 296 + const result: SavedFeedSourceInfo[] = [] 297 + for (let pinnedItem of pinnedItems) { 298 + const feedInfo = resolved.get(pinnedItem.value) 299 + if (feedInfo) { 300 + result.push({ 301 + ...feedInfo, 302 + savedFeed: pinnedItem, 303 + }) 304 + } else if (pinnedItem.type === 'timeline') { 305 + result.push({ 306 + type: 'feed', 307 + displayName: 'Following', 308 + uri: pinnedItem.value, 309 + feedDescriptor: 'following', 310 + route: { 311 + href: '/', 312 + name: 'Home', 313 + params: {}, 314 + }, 315 + cid: '', 316 + avatar: '', 317 + description: new RichText({text: ''}), 318 + creatorDid: '', 319 + creatorHandle: '', 320 + likeCount: 0, 321 + likeUri: '', 322 + savedFeed: pinnedItem, 323 + }) 297 324 } 298 325 } 299 326 return result
+2 -4
src/state/queries/post-feed.ts
··· 44 44 | 'posts_with_media' 45 45 type FeedUri = string 46 46 type ListUri = string 47 + 47 48 export type FeedDescriptor = 48 - | 'home' 49 49 | 'following' 50 50 | `author|${ActorDid}|${AuthorFilter}` 51 51 | `feedgen|${FeedUri}` ··· 390 390 userInterests?: string 391 391 getAgent: () => BskyAgent 392 392 }) { 393 - if (feedDesc === 'home') { 393 + if (feedDesc === 'following') { 394 394 if (feedParams.mergeFeedEnabled) { 395 395 return new MergeFeedAPI({ 396 396 getAgent, ··· 401 401 } else { 402 402 return new HomeFeedAPI({getAgent, userInterests}) 403 403 } 404 - } else if (feedDesc === 'following') { 405 - return new FollowingFeedAPI({getAgent}) 406 404 } else if (feedDesc.startsWith('author')) { 407 405 const [_, actor, filter] = feedDesc.split('|') 408 406 return new AuthorFeedAPI({getAgent, feedParams: {actor, filter}})
+3 -14
src/state/queries/preferences/const.ts
··· 1 + import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' 1 2 import { 2 - UsePreferencesQueryResponse, 3 3 ThreadViewPreferences, 4 + UsePreferencesQueryResponse, 4 5 } from '#/state/queries/preferences/types' 5 - import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' 6 6 7 7 export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = 8 8 { ··· 20 20 lab_treeViewEnabled: false, 21 21 } 22 22 23 - const DEFAULT_PROD_FEED_PREFIX = (rkey: string) => 24 - `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}` 25 - export const DEFAULT_PROD_FEEDS = { 26 - pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')], 27 - saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')], 28 - } 29 - 30 23 export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { 31 24 birthDate: new Date('2022-11-17'), // TODO(pwi) 32 - feeds: { 33 - saved: [], 34 - pinned: [], 35 - unpinned: [], 36 - }, 37 25 moderationPrefs: { 38 26 adultContentEnabled: false, 39 27 labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, ··· 45 33 threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, 46 34 userAge: 13, // TODO(pwi) 47 35 interests: {tags: []}, 36 + savedFeeds: [], 48 37 }
+28 -43
src/state/queries/preferences/index.ts
··· 51 51 52 52 const preferences: UsePreferencesQueryResponse = { 53 53 ...res, 54 - feeds: { 55 - saved: res.feeds?.saved || [], 56 - pinned: res.feeds?.pinned || [], 57 - unpinned: 58 - res.feeds.saved?.filter(f => { 59 - return !res.feeds.pinned?.includes(f) 60 - }) || [], 61 - }, 54 + savedFeeds: res.savedFeeds.filter(f => f.type !== 'unknown'), 55 + /** 56 + * Special preference, only used for following feed, previously 57 + * called `home` 58 + */ 62 59 feedViewPrefs: { 63 60 ...DEFAULT_HOME_FEED_PREFS, 64 61 ...(res.feedViewPrefs.home || {}), ··· 168 165 169 166 return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({ 170 167 mutationFn: async prefs => { 168 + /* 169 + * special handling here, merged into `feedViewPrefs` above, since 170 + * following was previously called `home` 171 + */ 171 172 await getAgent().setFeedViewPrefs('home', prefs) 172 173 // triggers a refetch 173 174 await queryClient.invalidateQueries({ ··· 192 193 }) 193 194 } 194 195 195 - export function useSetSaveFeedsMutation() { 196 + export function useOverwriteSavedFeedsMutation() { 196 197 const queryClient = useQueryClient() 197 198 const {getAgent} = useAgent() 198 199 199 - return useMutation< 200 - void, 201 - unknown, 202 - Pick<UsePreferencesQueryResponse['feeds'], 'saved' | 'pinned'> 203 - >({ 204 - mutationFn: async ({saved, pinned}) => { 205 - await getAgent().setSavedFeeds(saved, pinned) 200 + return useMutation<void, unknown, AppBskyActorDefs.SavedFeed[]>({ 201 + mutationFn: async savedFeeds => { 202 + await getAgent().overwriteSavedFeeds(savedFeeds) 206 203 // triggers a refetch 207 204 await queryClient.invalidateQueries({ 208 205 queryKey: preferencesQueryKey, ··· 211 208 }) 212 209 } 213 210 214 - export function useSaveFeedMutation() { 211 + export function useAddSavedFeedsMutation() { 215 212 const queryClient = useQueryClient() 216 213 const {getAgent} = useAgent() 217 214 218 - return useMutation<void, unknown, {uri: string}>({ 219 - mutationFn: async ({uri}) => { 220 - await getAgent().addSavedFeed(uri) 215 + return useMutation< 216 + void, 217 + unknown, 218 + Pick<AppBskyActorDefs.SavedFeed, 'type' | 'value' | 'pinned'>[] 219 + >({ 220 + mutationFn: async savedFeeds => { 221 + await getAgent().addSavedFeeds(savedFeeds) 221 222 track('CustomFeed:Save') 222 223 // triggers a refetch 223 224 await queryClient.invalidateQueries({ ··· 231 232 const queryClient = useQueryClient() 232 233 const {getAgent} = useAgent() 233 234 234 - return useMutation<void, unknown, {uri: string}>({ 235 - mutationFn: async ({uri}) => { 236 - await getAgent().removeSavedFeed(uri) 235 + return useMutation<void, unknown, Pick<AppBskyActorDefs.SavedFeed, 'id'>>({ 236 + mutationFn: async savedFeed => { 237 + await getAgent().removeSavedFeeds([savedFeed.id]) 237 238 track('CustomFeed:Unsave') 238 239 // triggers a refetch 239 240 await queryClient.invalidateQueries({ ··· 243 244 }) 244 245 } 245 246 246 - export function usePinFeedMutation() { 247 + export function useUpdateSavedFeedsMutation() { 247 248 const queryClient = useQueryClient() 248 249 const {getAgent} = useAgent() 249 250 250 - return useMutation<void, unknown, {uri: string}>({ 251 - mutationFn: async ({uri}) => { 252 - await getAgent().addPinnedFeed(uri) 253 - track('CustomFeed:Pin', {uri}) 254 - // triggers a refetch 255 - await queryClient.invalidateQueries({ 256 - queryKey: preferencesQueryKey, 257 - }) 258 - }, 259 - }) 260 - } 251 + return useMutation<void, unknown, AppBskyActorDefs.SavedFeed[]>({ 252 + mutationFn: async feeds => { 253 + await getAgent().updateSavedFeeds(feeds) 261 254 262 - export function useUnpinFeedMutation() { 263 - const queryClient = useQueryClient() 264 - const {getAgent} = useAgent() 265 - 266 - return useMutation<void, unknown, {uri: string}>({ 267 - mutationFn: async ({uri}) => { 268 - await getAgent().removePinnedFeed(uri) 269 - track('CustomFeed:Unpin', {uri}) 270 255 // triggers a refetch 271 256 await queryClient.invalidateQueries({ 272 257 queryKey: preferencesQueryKey,
+1 -4
src/state/queries/preferences/types.ts
··· 1 1 import { 2 + BskyFeedViewPreference, 2 3 BskyPreferences, 3 4 BskyThreadViewPreference, 4 - BskyFeedViewPreference, 5 5 } from '@atproto/api' 6 6 7 7 export type UsePreferencesQueryResponse = Omit< ··· 16 16 */ 17 17 threadViewPrefs: ThreadViewPreferences 18 18 userAge: number | undefined 19 - feeds: Required<BskyPreferences['feeds']> & { 20 - unpinned: string[] 21 - } 22 19 } 23 20 24 21 export type ThreadViewPreferences = Pick<
+29 -5
src/state/session/agent.ts
··· 1 1 import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' 2 + import {TID} from '@atproto/common-web' 2 3 3 4 import {networkRetry} from '#/lib/async/retry' 4 - import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' 5 - import {IS_PROD_SERVICE} from '#/lib/constants' 5 + import { 6 + DISCOVER_SAVED_FEED, 7 + IS_PROD_SERVICE, 8 + PUBLIC_BSKY_SERVICE, 9 + TIMELINE_SAVED_FEED, 10 + } from '#/lib/constants' 6 11 import {tryFetchGates} from '#/lib/statsig/statsig' 7 - import {DEFAULT_PROD_FEEDS} from '../queries/preferences' 12 + import {logger} from '#/logger' 8 13 import { 9 14 configureModerationForAccount, 10 15 configureModerationForGuest, ··· 134 139 135 140 // Not awaited so that we can still get into onboarding. 136 141 // This is OK because we won't let you toggle adult stuff until you set the date. 137 - agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 138 142 if (IS_PROD_SERVICE(service)) { 139 - agent.setSavedFeeds(DEFAULT_PROD_FEEDS.saved, DEFAULT_PROD_FEEDS.pinned) 143 + try { 144 + networkRetry(1, async () => { 145 + await agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 146 + await agent.overwriteSavedFeeds([ 147 + { 148 + ...DISCOVER_SAVED_FEED, 149 + id: TID.nextStr(), 150 + }, 151 + { 152 + ...TIMELINE_SAVED_FEED, 153 + id: TID.nextStr(), 154 + }, 155 + ]) 156 + }) 157 + } catch (e: any) { 158 + logger.error(e, { 159 + context: `session: createAgentAndCreateAccount failed to save personal details and feeds`, 160 + }) 161 + } 162 + } else { 163 + agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 140 164 } 141 165 142 166 return prepareAgent(agent, gates, moderation, onSessionChange)
+17 -18
src/state/shell/selected-feed.tsx
··· 1 1 import React from 'react' 2 2 3 - import {Gate} from '#/lib/statsig/gates' 4 - import {useGate} from '#/lib/statsig/statsig' 5 3 import {isWeb} from '#/platform/detection' 6 4 import * as persisted from '#/state/persisted' 5 + import {FeedDescriptor} from '#/state/queries/post-feed' 7 6 8 - type StateContext = string 9 - type SetContext = (v: string) => void 7 + type StateContext = FeedDescriptor | null 8 + type SetContext = (v: FeedDescriptor) => void 10 9 11 - const stateContext = React.createContext<StateContext>('home') 10 + const stateContext = React.createContext<StateContext>(null) 12 11 const setContext = React.createContext<SetContext>((_: string) => {}) 13 12 14 - function getInitialFeed(gate: (gateName: Gate) => boolean) { 13 + function getInitialFeed(): FeedDescriptor | null { 15 14 if (isWeb) { 16 15 if (window.location.pathname === '/') { 17 16 const params = new URLSearchParams(window.location.search) 18 17 const feedFromUrl = params.get('feed') 19 18 if (feedFromUrl) { 20 19 // If explicitly booted from a link like /?feed=..., prefer that. 21 - return feedFromUrl 20 + return feedFromUrl as FeedDescriptor 22 21 } 23 22 } 23 + 24 24 const feedFromSession = sessionStorage.getItem('lastSelectedHomeFeed') 25 25 if (feedFromSession) { 26 26 // Fall back to a previously chosen feed for this browser tab. 27 - return feedFromSession 27 + return feedFromSession as FeedDescriptor 28 28 } 29 29 } 30 - if (!gate('start_session_with_following_v2')) { 31 - const feedFromPersisted = persisted.get('lastSelectedHomeFeed') 32 - if (feedFromPersisted) { 33 - // Fall back to the last chosen one across all tabs. 34 - return feedFromPersisted 35 - } 30 + 31 + const feedFromPersisted = persisted.get('lastSelectedHomeFeed') 32 + if (feedFromPersisted) { 33 + // Fall back to the last chosen one across all tabs. 34 + return feedFromPersisted as FeedDescriptor 36 35 } 37 - return 'home' 36 + 37 + return null 38 38 } 39 39 40 40 export function Provider({children}: React.PropsWithChildren<{}>) { 41 - const gate = useGate() 42 - const [state, setState] = React.useState(() => getInitialFeed(gate)) 41 + const [state, setState] = React.useState(() => getInitialFeed()) 43 42 44 - const saveState = React.useCallback((feed: string) => { 43 + const saveState = React.useCallback((feed: FeedDescriptor) => { 45 44 setState(feed) 46 45 if (isWeb) { 47 46 try {
+6 -15
src/view/com/feeds/FeedPage.tsx
··· 1 1 import React from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs} from '@atproto/api' 3 4 import {msg} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 6 import {useNavigation} from '@react-navigation/native' ··· 17 18 import {useSetMinimalShellMode} from '#/state/shell' 18 19 import {useComposerControls} from '#/state/shell/composer' 19 20 import {useAnalytics} from 'lib/analytics/analytics' 20 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 21 21 import {ComposeIcon2} from 'lib/icons' 22 22 import {s} from 'lib/styles' 23 + import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' 23 24 import {Feed} from '../posts/Feed' 24 25 import {FAB} from '../util/fab/FAB' 25 26 import {ListMethods} from '../util/List' ··· 35 36 feedParams, 36 37 renderEmptyState, 37 38 renderEndOfFeed, 39 + savedFeedConfig, 38 40 }: { 39 41 testID?: string 40 42 feed: FeedDescriptor ··· 42 44 isPageFocused: boolean 43 45 renderEmptyState: () => JSX.Element 44 46 renderEndOfFeed?: () => JSX.Element 47 + savedFeedConfig?: AppBskyActorDefs.SavedFeed 45 48 }) { 46 49 const {hasSession} = useSession() 47 50 const {_} = useLingui() ··· 129 132 renderEmptyState={renderEmptyState} 130 133 renderEndOfFeed={renderEndOfFeed} 131 134 headerOffset={headerOffset} 135 + savedFeedConfig={savedFeedConfig} 132 136 /> 133 137 </FeedFeedbackProvider> 134 138 </MainScrollProvider> ··· 153 157 </View> 154 158 ) 155 159 } 156 - 157 - function useHeaderOffset() { 158 - const {isDesktop, isTablet} = useWebMediaQueries() 159 - const {fontScale} = useWindowDimensions() 160 - if (isDesktop || isTablet) { 161 - return 0 162 - } 163 - const navBarHeight = 42 164 - const tabBarPad = 10 + 10 + 3 // padding + border 165 - const normalLineHeight = 1.2 166 - const tabBarText = 16 * normalLineHeight * fontScale 167 - return navBarHeight + tabBarPad + tabBarText 168 - }
+38 -36
src/view/com/feeds/FeedSourceCard.tsx
··· 1 1 import React from 'react' 2 2 import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {Text} from '../util/text/Text' 5 - import {RichText} from '#/components/RichText' 6 - import {usePalette} from 'lib/hooks/usePalette' 7 - import {s} from 'lib/styles' 8 - import {UserAvatar} from '../util/UserAvatar' 9 3 import {AtUri} from '@atproto/api' 10 - import * as Toast from 'view/com/util/Toast' 11 - import {sanitizeHandle} from 'lib/strings/handles' 12 - import {logger} from '#/logger' 13 - import {Trans, msg, Plural} from '@lingui/macro' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {msg, Plural, Trans} from '@lingui/macro' 14 6 import {useLingui} from '@lingui/react' 7 + 8 + import {logger} from '#/logger' 9 + import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' 15 10 import { 16 - usePinFeedMutation, 17 - UsePreferencesQueryResponse, 11 + useAddSavedFeedsMutation, 18 12 usePreferencesQuery, 19 - useSaveFeedMutation, 13 + UsePreferencesQueryResponse, 20 14 useRemoveFeedMutation, 21 15 } from '#/state/queries/preferences' 22 - import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' 16 + import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' 17 + import {usePalette} from 'lib/hooks/usePalette' 18 + import {sanitizeHandle} from 'lib/strings/handles' 19 + import {s} from 'lib/styles' 23 20 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 21 + import * as Toast from 'view/com/util/Toast' 24 22 import {useTheme} from '#/alf' 23 + import {atoms as a} from '#/alf' 25 24 import * as Prompt from '#/components/Prompt' 26 - import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' 25 + import {RichText} from '#/components/RichText' 26 + import {Text} from '../util/text/Text' 27 + import {UserAvatar} from '../util/UserAvatar' 27 28 28 29 export function FeedSourceCard({ 29 30 feedUri, ··· 87 88 const removePromptControl = Prompt.usePromptControl() 88 89 const navigation = useNavigationDeduped() 89 90 90 - const {isPending: isSavePending, mutateAsync: saveFeed} = 91 - useSaveFeedMutation() 91 + const {isPending: isAddSavedFeedPending, mutateAsync: addSavedFeeds} = 92 + useAddSavedFeedsMutation() 92 93 const {isPending: isRemovePending, mutateAsync: removeFeed} = 93 94 useRemoveFeedMutation() 94 - const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() 95 95 96 - const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || '')) 96 + const savedFeedConfig = preferences?.savedFeeds?.find( 97 + f => f.value === feed?.uri, 98 + ) 99 + const isSaved = Boolean(savedFeedConfig) 97 100 98 101 const onSave = React.useCallback(async () => { 99 - if (!feed) return 102 + if (!feed || isSaved) return 100 103 101 104 try { 102 - if (pinOnSave) { 103 - await pinFeed({uri: feed.uri}) 104 - } else { 105 - await saveFeed({uri: feed.uri}) 106 - } 105 + await addSavedFeeds([ 106 + { 107 + type: 'feed', 108 + value: feed.uri, 109 + pinned: pinOnSave, 110 + }, 111 + ]) 107 112 Toast.show(_(msg`Added to my feeds`)) 108 113 } catch (e) { 109 114 Toast.show(_(msg`There was an issue contacting your server`)) 110 115 logger.error('Failed to save feed', {message: e}) 111 116 } 112 - }, [_, feed, pinFeed, pinOnSave, saveFeed]) 117 + }, [_, feed, pinOnSave, addSavedFeeds, isSaved]) 113 118 114 119 const onUnsave = React.useCallback(async () => { 115 - if (!feed) return 120 + if (!savedFeedConfig) return 116 121 117 122 try { 118 - await removeFeed({uri: feed.uri}) 123 + await removeFeed(savedFeedConfig) 119 124 // await item.unsave() 120 125 Toast.show(_(msg`Removed from my feeds`)) 121 126 } catch (e) { 122 127 Toast.show(_(msg`There was an issue contacting your server`)) 123 128 logger.error('Failed to unsave feed', {message: e}) 124 129 } 125 - }, [_, feed, removeFeed]) 130 + }, [_, removeFeed, savedFeedConfig]) 126 131 127 132 const onToggleSaved = React.useCallback(async () => { 128 - // Only feeds can be un/saved, lists are handled elsewhere 129 - if (feed?.type !== 'feed') return 130 - 131 133 if (isSaved) { 132 134 removePromptControl.open() 133 135 } else { 134 136 await onSave() 135 137 } 136 - }, [feed?.type, isSaved, removePromptControl, onSave]) 138 + }, [isSaved, removePromptControl, onSave]) 137 139 138 140 /* 139 141 * LOAD STATE ··· 204 206 } 205 207 }} 206 208 key={feed.uri}> 207 - <View style={[styles.headerContainer]}> 209 + <View style={[styles.headerContainer, a.align_start]}> 208 210 <View style={[s.mr10]}> 209 211 <UserAvatar type="algo" size={36} avatar={feed.avatar} /> 210 212 </View> ··· 221 223 </Text> 222 224 </View> 223 225 224 - {showSaveBtn && feed.type === 'feed' && ( 226 + {showSaveBtn && ( 225 227 <View style={[s.justifyCenter]}> 226 228 <Pressable 227 229 testID={`feed-${feed.displayName}-toggleSave`} 228 - disabled={isSavePending || isPinPending || isRemovePending} 230 + disabled={isAddSavedFeedPending || isRemovePending} 229 231 accessibilityRole="button" 230 232 accessibilityLabel={ 231 233 isSaved
+13 -6
src/view/com/home/HomeHeader.tsx
··· 1 1 import React from 'react' 2 - import {RenderTabBarFnProps} from 'view/com/pager/Pager' 3 - import {HomeHeaderLayout} from './HomeHeaderLayout' 4 - import {FeedSourceInfo} from '#/state/queries/feed' 5 2 import {useNavigation} from '@react-navigation/native' 3 + 4 + import {usePalette} from '#/lib/hooks/usePalette' 5 + import {FeedSourceInfo} from '#/state/queries/feed' 6 + import {useSession} from '#/state/session' 6 7 import {NavigationProp} from 'lib/routes/types' 7 8 import {isWeb} from 'platform/detection' 9 + import {RenderTabBarFnProps} from 'view/com/pager/Pager' 8 10 import {TabBar} from '../pager/TabBar' 9 - import {usePalette} from '#/lib/hooks/usePalette' 11 + import {HomeHeaderLayout} from './HomeHeaderLayout' 10 12 11 13 export function HomeHeader( 12 14 props: RenderTabBarFnProps & { ··· 16 18 }, 17 19 ) { 18 20 const {feeds} = props 21 + const {hasSession} = useSession() 19 22 const navigation = useNavigation<NavigationProp>() 20 23 const pal = usePalette('default') 21 24 22 25 const hasPinnedCustom = React.useMemo<boolean>(() => { 23 - return feeds.some(tab => tab.uri !== '') 24 - }, [feeds]) 26 + if (!hasSession) return false 27 + return feeds.some(tab => { 28 + const isFollowing = tab.uri === 'following' 29 + return !isFollowing 30 + }) 31 + }, [feeds, hasSession]) 25 32 26 33 const items = React.useMemo(() => { 27 34 const pinnedNames = feeds.map(f => f.displayName)
+13 -12
src/view/com/lightbox/Lightbox.tsx
··· 1 1 import React from 'react' 2 2 import {LayoutAnimation, StyleSheet, View} from 'react-native' 3 + import * as MediaLibrary from 'expo-media-library' 3 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import ImageView from './ImageViewing' 5 - import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip' 6 - import * as Toast from '../util/Toast' 7 - import {Text} from '../util/text/Text' 8 - import {s, colors} from 'lib/styles' 9 - import {Button} from '../util/forms/Button' 10 - import {isIOS} from 'platform/detection' 11 - import * as MediaLibrary from 'expo-media-library' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 12 8 import { 9 + ImagesLightbox, 10 + ProfileImageLightbox, 13 11 useLightbox, 14 12 useLightboxControls, 15 - ProfileImageLightbox, 16 - ImagesLightbox, 17 13 } from '#/state/lightbox' 18 - import {Trans, msg} from '@lingui/macro' 19 - import {useLingui} from '@lingui/react' 14 + import {saveImageToMediaLibrary, shareImageModal} from 'lib/media/manip' 15 + import {colors, s} from 'lib/styles' 16 + import {isIOS} from 'platform/detection' 17 + import {Button} from '../util/forms/Button' 18 + import {Text} from '../util/text/Text' 19 + import * as Toast from '../util/Toast' 20 + import ImageView from './ImageViewing' 20 21 21 22 export function Lightbox() { 22 23 const {activeLightbox} = useLightbox()
+7 -6
src/view/com/modals/SelfLabel.tsx
··· 1 1 import React, {useState} from 'react' 2 2 import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import {Text} from '../util/text/Text' 4 - import {s, colors} from 'lib/styles' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useModalControls} from '#/state/modals' 5 7 import {usePalette} from 'lib/hooks/usePalette' 6 8 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 9 + import {colors, s} from 'lib/styles' 7 10 import {isWeb} from 'platform/detection' 11 + import {ScrollView} from 'view/com/modals/util' 8 12 import {Button} from '../util/forms/Button' 9 13 import {SelectableBtn} from '../util/forms/SelectableBtn' 10 - import {ScrollView} from 'view/com/modals/util' 11 - import {Trans, msg} from '@lingui/macro' 12 - import {useLingui} from '@lingui/react' 13 - import {useModalControls} from '#/state/modals' 14 + import {Text} from '../util/text/Text' 14 15 15 16 const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn'] 16 17
+10 -6
src/view/com/pager/TabBar.tsx
··· 1 - import React, {useRef, useMemo, useEffect, useState, useCallback} from 'react' 2 - import {StyleSheet, View, ScrollView, LayoutChangeEvent} from 'react-native' 3 - import {Text} from '../util/text/Text' 4 - import {PressableWithHover} from '../util/PressableWithHover' 1 + import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 + import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' 3 + 4 + import {isNative} from '#/platform/detection' 5 5 import {usePalette} from 'lib/hooks/usePalette' 6 6 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 7 + import {PressableWithHover} from '../util/PressableWithHover' 8 + import {Text} from '../util/text/Text' 7 9 import {DraggableScrollView} from './DraggableScrollView' 8 - import {isNative} from '#/platform/detection' 9 10 10 11 export interface TabBarProps { 11 12 testID?: string ··· 139 140 <Text 140 141 type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'} 141 142 testID={testID ? `${testID}-${item}` : undefined} 142 - style={selected ? pal.text : pal.textLight}> 143 + style={[ 144 + selected ? pal.text : pal.textLight, 145 + {lineHeight: 20}, 146 + ]}> 143 147 {item} 144 148 </Text> 145 149 </View>
+13 -2
src/view/com/posts/Feed.tsx
··· 8 8 View, 9 9 ViewStyle, 10 10 } from 'react-native' 11 + import {AppBskyActorDefs} from '@atproto/api' 11 12 import {msg} from '@lingui/macro' 12 13 import {useLingui} from '@lingui/react' 13 14 import {useQueryClient} from '@tanstack/react-query' ··· 64 65 desktopFixedHeightOffset, 65 66 ListHeaderComponent, 66 67 extraData, 68 + savedFeedConfig, 67 69 }: { 68 70 feed: FeedDescriptor 69 71 feedParams?: FeedParams ··· 82 84 desktopFixedHeightOffset?: number 83 85 ListHeaderComponent?: () => JSX.Element 84 86 extraData?: any 87 + savedFeedConfig?: AppBskyActorDefs.SavedFeed 85 88 }): React.ReactNode => { 86 89 const theme = useTheme() 87 90 const {track} = useAnalytics() ··· 140 143 if ( 141 144 data?.pages.length === 1 && 142 145 (feed === 'following' || 143 - feed === 'home' || 144 146 feed === `author|${myDid}|posts_and_author_threads`) 145 147 ) { 146 148 queryClient.invalidateQueries({queryKey: RQKEY(feed)}) ··· 280 282 feedDesc={feed} 281 283 error={error ?? undefined} 282 284 onPressTryAgain={onPressTryAgain} 285 + savedFeedConfig={savedFeedConfig} 283 286 /> 284 287 ) 285 288 } else if (item === LOAD_MORE_ERROR_ITEM) { ··· 302 305 } 303 306 return <FeedSlice slice={item} /> 304 307 }, 305 - [feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState, _], 308 + [ 309 + feed, 310 + error, 311 + onPressTryAgain, 312 + onPressRetryLoadMore, 313 + renderEmptyState, 314 + _, 315 + savedFeedConfig, 316 + ], 306 317 ) 307 318 308 319 const shouldRenderEndOfFeed =
+31 -22
src/view/com/posts/FeedErrorMessage.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api' 4 - import {Text} from '../util/text/Text' 5 - import {Button} from '../util/forms/Button' 6 - import * as Toast from '../util/Toast' 7 - import {ErrorMessage} from '../util/error/ErrorMessage' 8 - import {usePalette} from 'lib/hooks/usePalette' 9 - import {useNavigation} from '@react-navigation/native' 10 - import {NavigationProp} from 'lib/routes/types' 11 - import {logger} from '#/logger' 3 + import {AppBskyActorDefs, AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api' 12 4 import {msg as msgLingui, Trans} from '@lingui/macro' 13 5 import {useLingui} from '@lingui/react' 6 + import {useNavigation} from '@react-navigation/native' 7 + 8 + import {cleanError} from '#/lib/strings/errors' 9 + import {logger} from '#/logger' 14 10 import {FeedDescriptor} from '#/state/queries/post-feed' 15 - import {EmptyState} from '../util/EmptyState' 16 - import {cleanError} from '#/lib/strings/errors' 17 11 import {useRemoveFeedMutation} from '#/state/queries/preferences' 12 + import {usePalette} from 'lib/hooks/usePalette' 13 + import {NavigationProp} from 'lib/routes/types' 18 14 import * as Prompt from '#/components/Prompt' 15 + import {EmptyState} from '../util/EmptyState' 16 + import {ErrorMessage} from '../util/error/ErrorMessage' 17 + import {Button} from '../util/forms/Button' 18 + import {Text} from '../util/text/Text' 19 + import * as Toast from '../util/Toast' 19 20 20 21 export enum KnownError { 21 22 Block = 'Block', ··· 33 34 feedDesc, 34 35 error, 35 36 onPressTryAgain, 37 + savedFeedConfig, 36 38 }: { 37 39 feedDesc: FeedDescriptor 38 40 error?: Error 39 41 onPressTryAgain: () => void 42 + savedFeedConfig?: AppBskyActorDefs.SavedFeed 40 43 }) { 41 44 const {_: _l} = useLingui() 42 45 const knownError = React.useMemo( ··· 46 49 if ( 47 50 typeof knownError !== 'undefined' && 48 51 knownError !== KnownError.Unknown && 49 - (feedDesc.startsWith('feedgen') || knownError === KnownError.FeedNSFPublic) 52 + (savedFeedConfig?.type === 'feed' || 53 + knownError === KnownError.FeedNSFPublic) 50 54 ) { 51 55 return ( 52 56 <FeedgenErrorMessage 53 57 feedDesc={feedDesc} 54 58 knownError={knownError} 55 59 rawError={error} 60 + savedFeedConfig={savedFeedConfig} 56 61 /> 57 62 ) 58 63 } ··· 79 84 feedDesc, 80 85 knownError, 81 86 rawError, 87 + savedFeedConfig, 82 88 }: { 83 89 feedDesc: FeedDescriptor 84 90 knownError: KnownError 85 91 rawError?: Error 92 + savedFeedConfig?: AppBskyActorDefs.SavedFeed 86 93 }) { 87 94 const pal = usePalette('default') 88 95 const {_: _l} = useLingui() ··· 131 138 132 139 const onRemoveFeed = React.useCallback(async () => { 133 140 try { 134 - await removeFeed({uri}) 141 + if (!savedFeedConfig) return 142 + await removeFeed(savedFeedConfig) 135 143 } catch (err) { 136 144 Toast.show( 137 145 _l( ··· 140 148 ) 141 149 logger.error('Failed to remove feed', {message: err}) 142 150 } 143 - }, [uri, removeFeed, _l]) 151 + }, [removeFeed, _l, savedFeedConfig]) 144 152 145 153 const cta = React.useMemo(() => { 146 154 switch (knownError) { ··· 154 162 case KnownError.FeedgenUnknown: { 155 163 return ( 156 164 <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> 157 - {knownError === KnownError.FeedgenDoesNotExist && ( 158 - <Button 159 - type="inverted" 160 - label={_l(msgLingui`Remove feed`)} 161 - onPress={onRemoveFeed} 162 - /> 163 - )} 165 + {knownError === KnownError.FeedgenDoesNotExist && 166 + savedFeedConfig && ( 167 + <Button 168 + type="inverted" 169 + label={_l(msgLingui`Remove feed`)} 170 + onPress={onRemoveFeed} 171 + /> 172 + )} 164 173 <Button 165 174 type="default-light" 166 175 label={_l(msgLingui`View profile`)} ··· 170 179 ) 171 180 } 172 181 } 173 - }, [knownError, onViewProfile, onRemoveFeed, _l]) 182 + }, [knownError, onViewProfile, onRemoveFeed, _l, savedFeedConfig]) 174 183 175 184 return ( 176 185 <>
+7 -6
src/view/com/util/post-ctrls/RepostButton.tsx
··· 1 1 import React, {memo, useCallback} from 'react' 2 2 import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native' 3 + import {msg, plural} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useModalControls} from '#/state/modals' 7 + import {useRequireAuth} from '#/state/session' 8 + import {HITSLOP_10, HITSLOP_20} from 'lib/constants' 3 9 import {RepostIcon} from 'lib/icons' 4 - import {s, colors} from 'lib/styles' 10 + import {colors, s} from 'lib/styles' 5 11 import {useTheme} from 'lib/ThemeContext' 6 12 import {Text} from '../text/Text' 7 - import {HITSLOP_10, HITSLOP_20} from 'lib/constants' 8 - import {useModalControls} from '#/state/modals' 9 - import {useRequireAuth} from '#/state/session' 10 - import {msg, plural} from '@lingui/macro' 11 - import {useLingui} from '@lingui/react' 12 13 13 14 interface Props { 14 15 isReposted: boolean
+135 -29
src/view/screens/Feeds.tsx
··· 6 6 StyleSheet, 7 7 View, 8 8 } from 'react-native' 9 + import {AppBskyActorDefs} from '@atproto/api' 9 10 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 10 11 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' 11 12 import {msg, Trans} from '@lingui/macro' ··· 44 45 import {Text} from 'view/com/util/text/Text' 45 46 import {UserAvatar} from 'view/com/util/UserAvatar' 46 47 import {ViewHeader} from 'view/com/util/ViewHeader' 48 + import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 49 + import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' 47 50 import {atoms as a, useTheme} from '#/alf' 48 51 import {IconCircle} from '#/components/IconCircle' 52 + import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 49 53 import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' 50 54 import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' 51 55 ··· 74 78 type: 'savedFeed' 75 79 key: string 76 80 feedUri: string 81 + savedFeedConfig: AppBskyActorDefs.SavedFeed 77 82 } 78 83 | { 79 84 type: 'savedFeedsLoadMore' ··· 98 103 } 99 104 | { 100 105 type: 'popularFeedsLoadingMore' 106 + key: string 107 + } 108 + | { 109 + type: 'noFollowingFeed' 101 110 key: string 102 111 } 103 112 ··· 229 238 error: cleanError(preferencesError.toString()), 230 239 }) 231 240 } else { 232 - if (isPreferencesLoading || !preferences?.feeds?.saved) { 241 + if (isPreferencesLoading || !preferences?.savedFeeds) { 233 242 slices.push({ 234 243 key: 'savedFeedsLoading', 235 244 type: 'savedFeedsLoading', 236 245 // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, 237 246 }) 238 247 } else { 239 - if (preferences?.feeds?.saved.length !== 0) { 240 - const {saved, pinned} = preferences.feeds 248 + if (preferences.savedFeeds?.length) { 249 + const noFollowingFeed = preferences.savedFeeds.every( 250 + f => f.type !== 'timeline', 251 + ) 241 252 242 253 slices = slices.concat( 243 - pinned.map(uri => ({ 244 - key: `savedFeed:${uri}`, 245 - type: 'savedFeed', 246 - feedUri: uri, 247 - })), 254 + preferences.savedFeeds 255 + .filter(f => { 256 + return f.pinned 257 + }) 258 + .map(feed => ({ 259 + key: `savedFeed:${feed.value}:${feed.id}`, 260 + type: 'savedFeed', 261 + feedUri: feed.value, 262 + savedFeedConfig: feed, 263 + })), 248 264 ) 249 - 250 265 slices = slices.concat( 251 - saved 252 - .filter(uri => !pinned.includes(uri)) 253 - .map(uri => ({ 254 - key: `savedFeed:${uri}`, 266 + preferences.savedFeeds 267 + .filter(f => { 268 + return !f.pinned 269 + }) 270 + .map(feed => ({ 271 + key: `savedFeed:${feed.value}:${feed.id}`, 255 272 type: 'savedFeed', 256 - feedUri: uri, 273 + feedUri: feed.value, 274 + savedFeedConfig: feed, 257 275 })), 258 276 ) 277 + 278 + if (noFollowingFeed) { 279 + slices.push({ 280 + key: 'noFollowingFeed', 281 + type: 'noFollowingFeed', 282 + }) 283 + } 284 + } else { 285 + slices.push({ 286 + key: 'savedFeedNoResults', 287 + type: 'savedFeedNoResults', 288 + }) 259 289 } 260 290 } 261 291 } ··· 323 353 ) { 324 354 return false 325 355 } 326 - return !preferences?.feeds?.saved.includes(feed.uri) 356 + const alreadySaved = Boolean( 357 + preferences?.savedFeeds?.find(f => { 358 + return f.value === feed.uri 359 + }), 360 + ) 361 + return !alreadySaved 327 362 }) 328 363 .map(feed => ({ 329 364 key: `popularFeed:${feed.uri}`, ··· 463 498 </View> 464 499 </View> 465 500 )} 466 - {preferences?.feeds?.saved?.length !== 0 && <FeedsSavedHeader />} 501 + <FeedsSavedHeader /> 467 502 </> 468 503 ) 469 504 } else if (item.type === 'savedFeedNoResults') { 470 505 return ( 471 506 <View 472 - style={{ 473 - paddingHorizontal: 16, 474 - paddingTop: 10, 475 - }}> 476 - <Text type="lg" style={pal.textLight}> 477 - <Trans>You don't have any saved feeds!</Trans> 478 - </Text> 507 + style={[ 508 + pal.border, 509 + { 510 + borderBottomWidth: 1, 511 + }, 512 + ]}> 513 + <NoSavedFeedsOfAnyType /> 479 514 </View> 480 515 ) 481 516 } else if (item.type === 'savedFeed') { 482 - return <SavedFeed feedUri={item.feedUri} /> 517 + return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} /> 483 518 } else if (item.type === 'popularFeedsHeader') { 484 519 return ( 485 520 <> ··· 521 556 </Text> 522 557 </View> 523 558 ) 559 + } else if (item.type === 'noFollowingFeed') { 560 + return ( 561 + <View 562 + style={[ 563 + pal.border, 564 + { 565 + borderBottomWidth: 1, 566 + }, 567 + ]}> 568 + <NoFollowingFeed /> 569 + </View> 570 + ) 524 571 } 525 572 return null 526 573 }, ··· 532 579 pal.icon, 533 580 pal.textLight, 534 581 _, 535 - preferences?.feeds?.saved?.length, 536 582 query, 537 583 onChangeQuery, 538 584 onPressCancelSearch, ··· 585 631 ) 586 632 } 587 633 588 - function SavedFeed({feedUri}: {feedUri: string}) { 634 + function FeedOrFollowing({ 635 + savedFeedConfig: feed, 636 + }: { 637 + savedFeedConfig: AppBskyActorDefs.SavedFeed 638 + }) { 639 + return feed.type === 'timeline' ? ( 640 + <FollowingFeed /> 641 + ) : ( 642 + <SavedFeed savedFeedConfig={feed} /> 643 + ) 644 + } 645 + 646 + function FollowingFeed() { 589 647 const pal = usePalette('default') 648 + const t = useTheme() 590 649 const {isMobile} = useWebMediaQueries() 591 - const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri}) 592 - const typeAvatar = getAvatarTypeFromUri(feedUri) 650 + return ( 651 + <View 652 + testID={`saved-feed-timeline`} 653 + style={[ 654 + pal.border, 655 + styles.savedFeed, 656 + isMobile && styles.savedFeedMobile, 657 + ]}> 658 + <View 659 + style={[ 660 + a.align_center, 661 + a.justify_center, 662 + { 663 + width: 28, 664 + height: 28, 665 + borderRadius: 3, 666 + backgroundColor: t.palette.primary_500, 667 + }, 668 + ]}> 669 + <FilterTimeline 670 + style={[ 671 + { 672 + width: 18, 673 + height: 18, 674 + }, 675 + ]} 676 + fill={t.palette.white} 677 + /> 678 + </View> 679 + <View 680 + style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> 681 + <Text type="lg-medium" style={pal.text} numberOfLines={1}> 682 + <Trans>Following</Trans> 683 + </Text> 684 + </View> 685 + </View> 686 + ) 687 + } 688 + 689 + function SavedFeed({ 690 + savedFeedConfig: feed, 691 + }: { 692 + savedFeedConfig: AppBskyActorDefs.SavedFeed 693 + }) { 694 + const pal = usePalette('default') 695 + const {isMobile} = useWebMediaQueries() 696 + const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value}) 697 + const typeAvatar = getAvatarTypeFromUri(feed.value) 593 698 594 699 if (!info) 595 700 return ( 596 701 <SavedFeedLoadingPlaceholder 597 - key={`savedFeedLoadingPlaceholder:${feedUri}`} 702 + key={`savedFeedLoadingPlaceholder:${feed.value}`} 598 703 /> 599 704 ) 600 705 ··· 632 737 </View> 633 738 ) : null} 634 739 </View> 740 + 635 741 {isMobile && ( 636 742 <FontAwesomeIcon 637 743 icon="chevron-right"
+52 -47
src/view/screens/Home.tsx
··· 8 8 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 9 9 import {logEvent, LogEvents, useGate} from '#/lib/statsig/statsig' 10 10 import {emitSoftReset} from '#/state/events' 11 - import {FeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' 12 - import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' 11 + import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' 12 + import {FeedParams} from '#/state/queries/post-feed' 13 13 import {usePreferencesQuery} from '#/state/queries/preferences' 14 14 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 15 15 import {useSession} from '#/state/session' ··· 26 26 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' 27 27 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' 28 28 import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' 29 + import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' 29 30 import {HomeHeader} from '../com/home/HomeHeader' 30 31 31 32 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> ··· 55 56 pinnedFeedInfos, 56 57 }: Props & { 57 58 preferences: UsePreferencesQueryResponse 58 - pinnedFeedInfos: FeedSourceInfo[] 59 + pinnedFeedInfos: SavedFeedSourceInfo[] 59 60 }) { 60 61 useOTAUpdates() 61 - 62 - const allFeeds = React.useMemo(() => { 63 - const feeds: FeedDescriptor[] = [] 64 - feeds.push('home') 65 - for (const {uri} of pinnedFeedInfos) { 66 - if (uri.includes('app.bsky.feed.generator')) { 67 - feeds.push(`feedgen|${uri}`) 68 - } else if (uri.includes('app.bsky.graph.list')) { 69 - feeds.push(`list|${uri}`) 70 - } 71 - } 72 - return feeds 73 - }, [pinnedFeedInfos]) 74 - 75 - const rawSelectedFeed = useSelectedFeed() 62 + const allFeeds = React.useMemo( 63 + () => pinnedFeedInfos.map(f => f.feedDescriptor), 64 + [pinnedFeedInfos], 65 + ) 66 + const rawSelectedFeed = useSelectedFeed() ?? allFeeds[0] 76 67 const setSelectedFeed = useSetSelectedFeed() 77 - const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor) 68 + const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed) 78 69 const selectedIndex = Math.max(0, maybeFoundIndex) 79 70 const selectedFeed = allFeeds[selectedIndex] 80 71 ··· 107 98 108 99 useFocusEffect( 109 100 useNonReactiveCallback(() => { 110 - logEvent('home:feedDisplayed', { 111 - index: selectedIndex, 112 - feedType: selectedFeed.split('|')[0], 113 - feedUrl: selectedFeed, 114 - reason: 'focus', 115 - }) 101 + if (selectedFeed) { 102 + logEvent('home:feedDisplayed', { 103 + index: selectedIndex, 104 + feedType: selectedFeed.split('|')[0], 105 + feedUrl: selectedFeed, 106 + reason: 'focus', 107 + }) 108 + } 116 109 }), 117 110 ) 118 111 ··· 198 191 return <CustomFeedEmptyState /> 199 192 }, []) 200 193 201 - const [homeFeed, ...customFeeds] = allFeeds 202 194 const homeFeedParams = React.useMemo<FeedParams>(() => { 203 195 return { 204 196 mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled), 205 197 mergeFeedSources: preferences.feedViewPrefs.lab_mergeFeedEnabled 206 - ? preferences.feeds.saved 198 + ? preferences.savedFeeds 199 + .filter(f => f.type === 'feed' || f.type === 'list') 200 + .map(f => f.value) 207 201 : [], 208 202 } 209 203 }, [preferences]) ··· 218 212 onPageSelected={onPageSelected} 219 213 onPageScrollStateChanged={onPageScrollStateChanged} 220 214 renderTabBar={renderTabBar}> 221 - <FeedPage 222 - key={homeFeed} 223 - testID="followingFeedPage" 224 - isPageFocused={selectedFeed === homeFeed} 225 - feed={homeFeed} 226 - feedParams={homeFeedParams} 227 - renderEmptyState={renderFollowingEmptyState} 228 - renderEndOfFeed={FollowingEndOfFeed} 229 - /> 230 - {customFeeds.map(feed => { 231 - return ( 232 - <FeedPage 233 - key={feed} 234 - testID="customFeedPage" 235 - isPageFocused={selectedFeed === feed} 236 - feed={feed} 237 - renderEmptyState={renderCustomFeedEmptyState} 238 - /> 239 - ) 240 - })} 215 + {pinnedFeedInfos.length ? ( 216 + pinnedFeedInfos.map(feedInfo => { 217 + const feed = feedInfo.feedDescriptor 218 + if (feed === 'following') { 219 + return ( 220 + <FeedPage 221 + key={feed} 222 + testID="followingFeedPage" 223 + isPageFocused={selectedFeed === feed} 224 + feed={feed} 225 + feedParams={homeFeedParams} 226 + renderEmptyState={renderFollowingEmptyState} 227 + renderEndOfFeed={FollowingEndOfFeed} 228 + /> 229 + ) 230 + } 231 + const savedFeedConfig = feedInfo.savedFeed 232 + return ( 233 + <FeedPage 234 + key={feed} 235 + testID="customFeedPage" 236 + isPageFocused={selectedFeed === feed} 237 + feed={feed} 238 + renderEmptyState={renderCustomFeedEmptyState} 239 + savedFeedConfig={savedFeedConfig} 240 + /> 241 + ) 242 + }) 243 + ) : ( 244 + <NoFeedsPinned preferences={preferences} /> 245 + )} 241 246 </Pager> 242 247 ) : ( 243 248 <Pager
+11 -10
src/view/screens/PreferencesFollowingFeed.tsx
··· 1 1 import React, {useState} from 'react' 2 2 import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' 3 3 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 + import {msg, Plural, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 4 6 import {Slider} from '@miblanchard/react-native-slider' 5 - import {Text} from '../com/util/text/Text' 6 - import {s, colors} from 'lib/styles' 7 + import debounce from 'lodash.debounce' 8 + 9 + import { 10 + usePreferencesQuery, 11 + useSetFeedViewPreferencesMutation, 12 + } from '#/state/queries/preferences' 7 13 import {usePalette} from 'lib/hooks/usePalette' 8 14 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 15 + import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 16 + import {colors, s} from 'lib/styles' 9 17 import {isWeb} from 'platform/detection' 10 18 import {ToggleButton} from 'view/com/util/forms/ToggleButton' 11 - import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 12 19 import {ViewHeader} from 'view/com/util/ViewHeader' 13 20 import {CenteredView} from 'view/com/util/Views' 14 - import debounce from 'lodash.debounce' 15 - import {Trans, msg, Plural} from '@lingui/macro' 16 - import {useLingui} from '@lingui/react' 17 - import { 18 - usePreferencesQuery, 19 - useSetFeedViewPreferencesMutation, 20 - } from '#/state/queries/preferences' 21 + import {Text} from '../com/util/text/Text' 21 22 22 23 function RepliesThresholdInput({ 23 24 enabled,
+45 -64
src/view/screens/ProfileFeed.tsx
··· 16 16 import {FeedDescriptor} from '#/state/queries/post-feed' 17 17 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 18 18 import { 19 - usePinFeedMutation, 19 + useAddSavedFeedsMutation, 20 20 usePreferencesQuery, 21 21 UsePreferencesQueryResponse, 22 22 useRemoveFeedMutation, 23 - useSaveFeedMutation, 24 - useUnpinFeedMutation, 23 + useUpdateSavedFeedsMutation, 25 24 } from '#/state/queries/preferences' 26 25 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 27 26 import {truncateAndInvalidate} from '#/state/queries/util' ··· 163 162 const feedSectionRef = React.useRef<SectionRef>(null) 164 163 const isScreenFocused = useIsFocused() 165 164 166 - const { 167 - mutateAsync: saveFeed, 168 - variables: savedFeed, 169 - reset: resetSaveFeed, 170 - isPending: isSavePending, 171 - } = useSaveFeedMutation() 172 - const { 173 - mutateAsync: removeFeed, 174 - variables: removedFeed, 175 - reset: resetRemoveFeed, 176 - isPending: isRemovePending, 177 - } = useRemoveFeedMutation() 178 - const { 179 - mutateAsync: pinFeed, 180 - variables: pinnedFeed, 181 - reset: resetPinFeed, 182 - isPending: isPinPending, 183 - } = usePinFeedMutation() 184 - const { 185 - mutateAsync: unpinFeed, 186 - variables: unpinnedFeed, 187 - reset: resetUnpinFeed, 188 - isPending: isUnpinPending, 189 - } = useUnpinFeedMutation() 165 + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = 166 + useAddSavedFeedsMutation() 167 + const {mutateAsync: removeFeed, isPending: isRemovePending} = 168 + useRemoveFeedMutation() 169 + const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = 170 + useUpdateSavedFeedsMutation() 190 171 191 - const isSaved = 192 - !removedFeed && 193 - (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri)) 194 - const isPinned = 195 - !unpinnedFeed && 196 - (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri)) 172 + const isPending = 173 + isAddSavedFeedPending || isRemovePending || isUpdateFeedPending 174 + const savedFeedConfig = preferences.savedFeeds.find( 175 + f => f.value === feedInfo.uri, 176 + ) 177 + const isSaved = Boolean(savedFeedConfig) 178 + const isPinned = Boolean(savedFeedConfig?.pinned) 197 179 198 180 useSetTitle(feedInfo?.displayName) 199 181 ··· 204 186 try { 205 187 playHaptic() 206 188 207 - if (isSaved) { 208 - await removeFeed({uri: feedInfo.uri}) 209 - resetRemoveFeed() 189 + if (savedFeedConfig) { 190 + await removeFeed(savedFeedConfig) 210 191 Toast.show(_(msg`Removed from your feeds`)) 211 192 } else { 212 - await saveFeed({uri: feedInfo.uri}) 213 - resetSaveFeed() 193 + await addSavedFeeds([ 194 + { 195 + type: 'feed', 196 + value: feedInfo.uri, 197 + pinned: false, 198 + }, 199 + ]) 214 200 Toast.show(_(msg`Saved to your feeds`)) 215 201 } 216 202 } catch (err) { ··· 221 207 ) 222 208 logger.error('Failed up update feeds', {message: err}) 223 209 } 224 - }, [ 225 - playHaptic, 226 - isSaved, 227 - removeFeed, 228 - feedInfo, 229 - resetRemoveFeed, 230 - _, 231 - saveFeed, 232 - resetSaveFeed, 233 - ]) 210 + }, [_, playHaptic, feedInfo, removeFeed, addSavedFeeds, savedFeedConfig]) 234 211 235 212 const onTogglePinned = React.useCallback(async () => { 236 213 try { 237 214 playHaptic() 238 215 239 - if (isPinned) { 240 - await unpinFeed({uri: feedInfo.uri}) 241 - resetUnpinFeed() 216 + if (savedFeedConfig) { 217 + await updateSavedFeeds([ 218 + { 219 + ...savedFeedConfig, 220 + pinned: !savedFeedConfig.pinned, 221 + }, 222 + ]) 242 223 } else { 243 - await pinFeed({uri: feedInfo.uri}) 244 - resetPinFeed() 224 + await addSavedFeeds([ 225 + { 226 + type: 'feed', 227 + value: feedInfo.uri, 228 + pinned: true, 229 + }, 230 + ]) 245 231 } 246 232 } catch (e) { 247 233 Toast.show(_(msg`There was an issue contacting the server`)) ··· 249 235 } 250 236 }, [ 251 237 playHaptic, 252 - isPinned, 253 - unpinFeed, 254 238 feedInfo, 255 - resetUnpinFeed, 256 - pinFeed, 257 - resetPinFeed, 258 239 _, 240 + savedFeedConfig, 241 + updateSavedFeeds, 242 + addSavedFeeds, 259 243 ]) 260 244 261 245 const onPressShare = React.useCallback(() => { ··· 296 280 {feedInfo && hasSession && ( 297 281 <NewButton 298 282 testID={isPinned ? 'unpinBtn' : 'pinBtn'} 299 - disabled={isPinPending || isUnpinPending} 283 + disabled={isPending} 300 284 size="small" 301 285 variant="solid" 302 286 color={isPinned ? 'secondary' : 'primary'} ··· 339 323 {hasSession && ( 340 324 <> 341 325 <Menu.Item 342 - disabled={isSavePending || isRemovePending} 326 + disabled={isPending} 343 327 testID="feedHeaderDropdownToggleSavedBtn" 344 328 label={ 345 329 isSaved ··· 395 379 onTogglePinned, 396 380 onToggleSaved, 397 381 currentAccount?.did, 398 - isPinPending, 399 - isRemovePending, 400 - isSavePending, 401 382 isSaved, 402 - isUnpinPending, 403 383 onPressReport, 404 384 onPressShare, 405 385 t, 386 + isPending, 406 387 ]) 407 388 408 389 return (
+81 -28
src/view/screens/ProfileList.tsx
··· 23 23 import {FeedDescriptor} from '#/state/queries/post-feed' 24 24 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 25 25 import { 26 - usePinFeedMutation, 26 + useAddSavedFeedsMutation, 27 27 usePreferencesQuery, 28 - useSetSaveFeedsMutation, 29 - useUnpinFeedMutation, 28 + useRemoveFeedMutation, 29 + useUpdateSavedFeedsMutation, 30 30 } from '#/state/queries/preferences' 31 31 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 32 32 import {truncateAndInvalidate} from '#/state/queries/util' ··· 248 248 const isBlocking = !!list.viewer?.blocked 249 249 const isMuting = !!list.viewer?.muted 250 250 const isOwner = list.creator.did === currentAccount?.did 251 - const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() 252 - const {isPending: isUnpinPending, mutateAsync: unpinFeed} = 253 - useUnpinFeedMutation() 254 - const isPending = isPinPending || isUnpinPending 255 251 const {data: preferences} = usePreferencesQuery() 256 - const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() 257 252 const {track} = useAnalytics() 258 253 const playHaptic = useHaptics() 259 254 255 + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = 256 + useAddSavedFeedsMutation() 257 + const {mutateAsync: removeSavedFeed, isPending: isRemovePending} = 258 + useRemoveFeedMutation() 259 + const {mutateAsync: updateSavedFeeds, isPending: isUpdatingSavedFeeds} = 260 + useUpdateSavedFeedsMutation() 261 + 262 + const isPending = 263 + isAddSavedFeedPending || isRemovePending || isUpdatingSavedFeeds 264 + 260 265 const deleteListPromptControl = useDialogControl() 261 266 const subscribeMutePromptControl = useDialogControl() 262 267 const subscribeBlockPromptControl = useDialogControl() 263 268 264 - const isPinned = preferences?.feeds?.pinned?.includes(list.uri) 265 - const isSaved = preferences?.feeds?.saved?.includes(list.uri) 269 + const savedFeedConfig = preferences?.savedFeeds?.find( 270 + f => f.value === list.uri, 271 + ) 272 + const isPinned = Boolean(savedFeedConfig?.pinned) 266 273 267 274 const onTogglePinned = React.useCallback(async () => { 268 275 playHaptic() 269 276 270 277 try { 271 - if (isPinned) { 272 - await unpinFeed({uri: list.uri}) 278 + if (savedFeedConfig) { 279 + const pinned = !savedFeedConfig.pinned 280 + await updateSavedFeeds([ 281 + { 282 + ...savedFeedConfig, 283 + pinned, 284 + }, 285 + ]) 286 + Toast.show(_(msg`${pinned ? 'Pinned to' : 'Unpinned from'} your feeds`)) 273 287 } else { 274 - await pinFeed({uri: list.uri}) 288 + await addSavedFeeds([ 289 + { 290 + type: 'list', 291 + value: list.uri, 292 + pinned: true, 293 + }, 294 + ]) 295 + Toast.show(_(msg`Saved to your feeds`)) 275 296 } 276 297 } catch (e) { 277 298 Toast.show(_(msg`There was an issue contacting the server`)) 278 299 logger.error('Failed to toggle pinned feed', {message: e}) 279 300 } 280 - }, [playHaptic, isPinned, unpinFeed, list.uri, pinFeed, _]) 301 + }, [ 302 + playHaptic, 303 + addSavedFeeds, 304 + updateSavedFeeds, 305 + list.uri, 306 + _, 307 + savedFeedConfig, 308 + ]) 309 + 310 + const onRemoveFromSavedFeeds = React.useCallback(async () => { 311 + playHaptic() 312 + if (!savedFeedConfig) return 313 + try { 314 + await removeSavedFeed(savedFeedConfig) 315 + Toast.show(_(msg`Removed from your feeds`)) 316 + } catch (e) { 317 + Toast.show(_(msg`There was an issue contacting the server`)) 318 + logger.error('Failed to remove pinned list', {message: e}) 319 + } 320 + }, [playHaptic, removeSavedFeed, _, savedFeedConfig]) 281 321 282 322 const onSubscribeMute = useCallback(async () => { 283 323 try { ··· 345 385 const onPressDelete = useCallback(async () => { 346 386 await listDeleteMutation.mutateAsync({uri: list.uri}) 347 387 348 - if (isSaved || isPinned) { 349 - const {saved, pinned} = preferences!.feeds 350 - 351 - setSavedFeeds({ 352 - saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, 353 - pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, 354 - }) 388 + if (savedFeedConfig) { 389 + await removeSavedFeed(savedFeedConfig) 355 390 } 356 391 357 392 Toast.show(_(msg`List deleted`)) ··· 367 402 navigation, 368 403 track, 369 404 _, 370 - preferences, 371 - isPinned, 372 - isSaved, 373 - setSavedFeeds, 405 + removeSavedFeed, 406 + savedFeedConfig, 374 407 ]) 375 408 376 409 const onPressReport = useCallback(() => { ··· 398 431 }, 399 432 }, 400 433 ] 434 + 435 + if (savedFeedConfig) { 436 + items.push({ 437 + testID: 'listHeaderDropdownRemoveFromFeedsBtn', 438 + label: _(msg`Remove from my feeds`), 439 + onPress: onRemoveFromSavedFeeds, 440 + icon: { 441 + ios: { 442 + name: 'trash', 443 + }, 444 + android: '', 445 + web: ['far', 'trash-can'], 446 + }, 447 + }) 448 + } 449 + 401 450 if (isOwner) { 402 451 items.push({label: 'separator'}) 403 452 items.push({ ··· 444 493 items.push({ 445 494 testID: 'listHeaderDropdownUnpinBtn', 446 495 label: _(msg`Unpin moderation list`), 447 - onPress: isPending ? undefined : () => unpinFeed({uri: list.uri}), 496 + onPress: 497 + isPending || !savedFeedConfig 498 + ? undefined 499 + : () => removeSavedFeed(savedFeedConfig), 448 500 icon: { 449 501 ios: { 450 502 name: 'pin', ··· 499 551 deleteListPromptControl.open, 500 552 onPressReport, 501 553 isPending, 502 - unpinFeed, 503 - list.uri, 504 554 isBlocking, 505 555 isMuting, 506 556 onUnsubscribeMute, 507 557 onUnsubscribeBlock, 558 + removeSavedFeed, 559 + savedFeedConfig, 560 + onRemoveFromSavedFeeds, 508 561 ]) 509 562 510 563 const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
+169 -92
src/view/screens/SavedFeeds.tsx
··· 1 1 import React from 'react' 2 2 import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' 3 + import {AppBskyActorDefs} from '@atproto/api' 3 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 5 import {msg, Trans} from '@lingui/macro' 5 6 import {useLingui} from '@lingui/react' ··· 9 10 import {track} from '#/lib/analytics/analytics' 10 11 import {logger} from '#/logger' 11 12 import { 12 - usePinFeedMutation, 13 + useOverwriteSavedFeedsMutation, 13 14 usePreferencesQuery, 14 - useSetSaveFeedsMutation, 15 - useUnpinFeedMutation, 15 + useUpdateSavedFeedsMutation, 16 16 } from '#/state/queries/preferences' 17 + import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 17 18 import {useSetMinimalShellMode} from '#/state/shell' 18 19 import {useAnalytics} from 'lib/analytics/analytics' 19 20 import {useHaptics} from 'lib/haptics' ··· 27 28 import * as Toast from 'view/com/util/Toast' 28 29 import {ViewHeader} from 'view/com/util/ViewHeader' 29 30 import {CenteredView, ScrollView} from 'view/com/util/Views' 31 + import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 32 + import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' 33 + import {atoms as a, useTheme} from '#/alf' 34 + import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 30 35 31 36 const HITSLOP_TOP = { 32 37 top: 20, ··· 50 55 const setMinimalShellMode = useSetMinimalShellMode() 51 56 const {data: preferences} = usePreferencesQuery() 52 57 const { 53 - mutateAsync: setSavedFeeds, 58 + mutateAsync: overwriteSavedFeeds, 54 59 variables: optimisticSavedFeedsResponse, 55 60 reset: resetSaveFeedsMutationState, 56 - error: setSavedFeedsError, 57 - } = useSetSaveFeedsMutation() 61 + error: savedFeedsError, 62 + } = useOverwriteSavedFeedsMutation() 58 63 59 64 /* 60 65 * Use optimistic data if exists and no error, otherwise fallback to remote 61 66 * data 62 67 */ 63 68 const currentFeeds = 64 - optimisticSavedFeedsResponse && !setSavedFeedsError 69 + optimisticSavedFeedsResponse && !savedFeedsError 65 70 ? optimisticSavedFeedsResponse 66 - : preferences?.feeds || {saved: [], pinned: []} 67 - const unpinned = currentFeeds.saved.filter(f => { 68 - return !currentFeeds.pinned?.includes(f) 69 - }) 71 + : preferences?.savedFeeds || [] 72 + const pinnedFeeds = currentFeeds.filter(f => f.pinned) 73 + const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) 74 + const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 75 + const noFollowingFeed = 76 + currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType 70 77 71 78 useFocusEffect( 72 79 React.useCallback(() => { ··· 84 91 ]}> 85 92 <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder /> 86 93 <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}> 94 + {noSavedFeedsOfAnyType && ( 95 + <View style={[pal.border, {borderBottomWidth: 1}]}> 96 + <NoSavedFeedsOfAnyType /> 97 + </View> 98 + )} 99 + 87 100 <View style={[pal.text, pal.border, styles.title]}> 88 101 <Text type="title" style={pal.text}> 89 102 <Trans>Pinned Feeds</Trans> 90 103 </Text> 91 104 </View> 92 105 93 - {preferences?.feeds ? ( 94 - !currentFeeds.pinned.length ? ( 106 + {preferences ? ( 107 + !pinnedFeeds.length ? ( 95 108 <View 96 109 style={[ 97 110 pal.border, ··· 104 117 </Text> 105 118 </View> 106 119 ) : ( 107 - currentFeeds.pinned.map(uri => ( 120 + pinnedFeeds.map(f => ( 108 121 <ListItem 109 - key={uri} 110 - feedUri={uri} 122 + key={f.id} 123 + feed={f} 111 124 isPinned 112 - setSavedFeeds={setSavedFeeds} 125 + overwriteSavedFeeds={overwriteSavedFeeds} 113 126 resetSaveFeedsMutationState={resetSaveFeedsMutationState} 114 127 currentFeeds={currentFeeds} 128 + preferences={preferences} 115 129 /> 116 130 )) 117 131 ) 118 132 ) : ( 119 133 <ActivityIndicator style={{marginTop: 20}} /> 120 134 )} 135 + 136 + {noFollowingFeed && ( 137 + <View style={[pal.border, {borderBottomWidth: 1}]}> 138 + <NoFollowingFeed /> 139 + </View> 140 + )} 141 + 121 142 <View style={[pal.text, pal.border, styles.title]}> 122 143 <Text type="title" style={pal.text}> 123 144 <Trans>Saved Feeds</Trans> 124 145 </Text> 125 146 </View> 126 - {preferences?.feeds ? ( 127 - !unpinned.length ? ( 147 + {preferences ? ( 148 + !unpinnedFeeds.length ? ( 128 149 <View 129 150 style={[ 130 151 pal.border, ··· 137 158 </Text> 138 159 </View> 139 160 ) : ( 140 - unpinned.map(uri => ( 161 + unpinnedFeeds.map(f => ( 141 162 <ListItem 142 - key={uri} 143 - feedUri={uri} 163 + key={f.id} 164 + feed={f} 144 165 isPinned={false} 145 - setSavedFeeds={setSavedFeeds} 166 + overwriteSavedFeeds={overwriteSavedFeeds} 146 167 resetSaveFeedsMutationState={resetSaveFeedsMutationState} 147 168 currentFeeds={currentFeeds} 169 + preferences={preferences} 148 170 /> 149 171 )) 150 172 ) ··· 174 196 } 175 197 176 198 function ListItem({ 177 - feedUri, 199 + feed, 178 200 isPinned, 179 201 currentFeeds, 180 - setSavedFeeds, 202 + overwriteSavedFeeds, 181 203 resetSaveFeedsMutationState, 182 204 }: { 183 - feedUri: string // uri 205 + feed: AppBskyActorDefs.SavedFeed 184 206 isPinned: boolean 185 - currentFeeds: {saved: string[]; pinned: string[]} 186 - setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync'] 207 + currentFeeds: AppBskyActorDefs.SavedFeed[] 208 + overwriteSavedFeeds: ReturnType< 209 + typeof useOverwriteSavedFeedsMutation 210 + >['mutateAsync'] 187 211 resetSaveFeedsMutationState: ReturnType< 188 - typeof useSetSaveFeedsMutation 212 + typeof useOverwriteSavedFeedsMutation 189 213 >['reset'] 214 + preferences: UsePreferencesQueryResponse 190 215 }) { 191 216 const pal = usePalette('default') 192 217 const {_} = useLingui() 193 218 const playHaptic = useHaptics() 194 - const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() 195 - const {isPending: isUnpinPending, mutateAsync: unpinFeed} = 196 - useUnpinFeedMutation() 197 - const isPending = isPinPending || isUnpinPending 219 + const {isPending: isUpdatePending, mutateAsync: updateSavedFeeds} = 220 + useUpdateSavedFeedsMutation() 221 + const feedUri = feed.value 198 222 199 223 const onTogglePinned = React.useCallback(async () => { 200 224 playHaptic() ··· 202 226 try { 203 227 resetSaveFeedsMutationState() 204 228 205 - if (isPinned) { 206 - await unpinFeed({uri: feedUri}) 207 - } else { 208 - await pinFeed({uri: feedUri}) 209 - } 229 + await updateSavedFeeds([ 230 + { 231 + ...feed, 232 + pinned: !feed.pinned, 233 + }, 234 + ]) 210 235 } catch (e) { 211 236 Toast.show(_(msg`There was an issue contacting the server`)) 212 237 logger.error('Failed to toggle pinned feed', {message: e}) 213 238 } 214 - }, [ 215 - playHaptic, 216 - resetSaveFeedsMutationState, 217 - isPinned, 218 - unpinFeed, 219 - feedUri, 220 - pinFeed, 221 - _, 222 - ]) 239 + }, [_, playHaptic, feed, updateSavedFeeds, resetSaveFeedsMutationState]) 223 240 224 241 const onPressUp = React.useCallback(async () => { 225 242 if (!isPinned) return 226 243 227 - // create new array, do not mutate 228 - const pinned = [...currentFeeds.pinned] 229 - const index = pinned.indexOf(feedUri) 244 + const nextFeeds = currentFeeds.slice() 245 + const ids = currentFeeds.map(f => f.id) 246 + const index = ids.indexOf(feed.id) 247 + const nextIndex = index - 1 230 248 231 249 if (index === -1 || index === 0) return 232 - ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] 250 + ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 251 + nextFeeds[nextIndex], 252 + nextFeeds[index], 253 + ] 233 254 234 255 try { 235 - await setSavedFeeds({saved: currentFeeds.saved, pinned}) 256 + await overwriteSavedFeeds(nextFeeds) 236 257 track('CustomFeed:Reorder', { 237 - uri: feedUri, 238 - index: pinned.indexOf(feedUri), 258 + uri: feed.value, 259 + index: nextIndex, 239 260 }) 240 261 } catch (e) { 241 262 Toast.show(_(msg`There was an issue contacting the server`)) 242 263 logger.error('Failed to set pinned feed order', {message: e}) 243 264 } 244 - }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _]) 265 + }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _]) 245 266 246 267 const onPressDown = React.useCallback(async () => { 247 268 if (!isPinned) return 248 269 249 - const pinned = [...currentFeeds.pinned] 250 - const index = pinned.indexOf(feedUri) 270 + const nextFeeds = currentFeeds.slice() 271 + const ids = currentFeeds.map(f => f.id) 272 + const index = ids.indexOf(feed.id) 273 + const nextIndex = index + 1 251 274 252 - if (index === -1 || index >= pinned.length - 1) return 253 - ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] 275 + if (index === -1 || index >= nextFeeds.length - 1) return 276 + ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 277 + nextFeeds[nextIndex], 278 + nextFeeds[index], 279 + ] 254 280 255 281 try { 256 - await setSavedFeeds({saved: currentFeeds.saved, pinned}) 282 + await overwriteSavedFeeds(nextFeeds) 257 283 track('CustomFeed:Reorder', { 258 - uri: feedUri, 259 - index: pinned.indexOf(feedUri), 284 + uri: feed.value, 285 + index: nextIndex, 260 286 }) 261 287 } catch (e) { 262 288 Toast.show(_(msg`There was an issue contacting the server`)) 263 289 logger.error('Failed to set pinned feed order', {message: e}) 264 290 } 265 - }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _]) 291 + }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _]) 266 292 267 293 return ( 268 - <Pressable 269 - accessibilityRole="button" 270 - style={[styles.itemContainer, pal.border]}> 294 + <View style={[styles.itemContainer, pal.border]}> 271 295 {isPinned ? ( 272 296 <View style={styles.webArrowButtonsContainer}> 273 297 <Pressable 274 - disabled={isPending} 298 + disabled={isUpdatePending} 275 299 accessibilityRole="button" 276 300 onPress={onPressUp} 277 301 hitSlop={HITSLOP_TOP} 278 302 style={state => ({ 279 - opacity: state.hovered || state.focused || isPending ? 0.5 : 1, 303 + opacity: 304 + state.hovered || state.focused || isUpdatePending ? 0.5 : 1, 280 305 })}> 281 306 <FontAwesomeIcon 282 307 icon="arrow-up" ··· 285 310 /> 286 311 </Pressable> 287 312 <Pressable 288 - disabled={isPending} 313 + disabled={isUpdatePending} 289 314 accessibilityRole="button" 290 315 onPress={onPressDown} 291 316 hitSlop={HITSLOP_BOTTOM} 292 317 style={state => ({ 293 - opacity: state.hovered || state.focused || isPending ? 0.5 : 1, 318 + opacity: 319 + state.hovered || state.focused || isUpdatePending ? 0.5 : 1, 294 320 })}> 295 321 <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} /> 296 322 </Pressable> 297 323 </View> 298 324 ) : null} 299 - <FeedSourceCard 300 - key={feedUri} 301 - feedUri={feedUri} 302 - style={styles.noTopBorder} 303 - showSaveBtn 304 - showMinimalPlaceholder 305 - /> 306 - <Pressable 307 - disabled={isPending} 308 - accessibilityRole="button" 309 - hitSlop={10} 310 - onPress={onTogglePinned} 311 - style={state => ({ 312 - opacity: state.hovered || state.focused || isPending ? 0.5 : 1, 313 - })}> 314 - <FontAwesomeIcon 315 - icon="thumb-tack" 316 - size={20} 317 - color={isPinned ? colors.blue3 : pal.colors.icon} 325 + {feed.type === 'timeline' ? ( 326 + <FollowingFeedCard /> 327 + ) : ( 328 + <FeedSourceCard 329 + key={feedUri} 330 + feedUri={feedUri} 331 + style={styles.noTopBorder} 332 + showSaveBtn 333 + showMinimalPlaceholder 318 334 /> 319 - </Pressable> 320 - </Pressable> 335 + )} 336 + <View style={{paddingRight: 16}}> 337 + <Pressable 338 + disabled={isUpdatePending} 339 + accessibilityRole="button" 340 + hitSlop={10} 341 + onPress={onTogglePinned} 342 + style={state => ({ 343 + opacity: 344 + state.hovered || state.focused || isUpdatePending ? 0.5 : 1, 345 + })}> 346 + <FontAwesomeIcon 347 + icon="thumb-tack" 348 + size={20} 349 + color={isPinned ? colors.blue3 : pal.colors.icon} 350 + /> 351 + </Pressable> 352 + </View> 353 + </View> 354 + ) 355 + } 356 + 357 + function FollowingFeedCard() { 358 + const t = useTheme() 359 + return ( 360 + <View 361 + style={[ 362 + a.flex_row, 363 + a.align_center, 364 + a.flex_1, 365 + { 366 + paddingHorizontal: 18, 367 + paddingVertical: 20, 368 + }, 369 + ]}> 370 + <View 371 + style={[ 372 + a.align_center, 373 + a.justify_center, 374 + a.rounded_sm, 375 + { 376 + width: 36, 377 + height: 36, 378 + backgroundColor: t.palette.primary_500, 379 + marginRight: 10, 380 + }, 381 + ]}> 382 + <FilterTimeline 383 + style={[ 384 + { 385 + width: 22, 386 + height: 22, 387 + }, 388 + ]} 389 + fill={t.palette.white} 390 + /> 391 + </View> 392 + <View 393 + style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> 394 + <Text type="lg-medium" style={[t.atoms.text]} numberOfLines={1}> 395 + <Trans>Following</Trans> 396 + </Text> 397 + </View> 398 + </View> 321 399 ) 322 400 } 323 401 ··· 345 423 flexDirection: 'row', 346 424 alignItems: 'center', 347 425 borderBottomWidth: 1, 348 - paddingRight: 16, 349 426 }, 350 427 webArrowButtonsContainer: { 351 428 paddingLeft: 16,
+9 -19
src/view/shell/desktop/Feeds.tsx
··· 1 1 import React from 'react' 2 - import {View, StyleSheet} from 'react-native' 3 - import {useNavigationState, useNavigation} from '@react-navigation/native' 4 - import {usePalette} from 'lib/hooks/usePalette' 5 - import {TextLink} from 'view/com/util/Link' 6 - import {getCurrentRoute} from 'lib/routes/helpers' 7 - import {useLingui} from '@lingui/react' 2 + import {StyleSheet, View} from 'react-native' 8 3 import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useNavigation, useNavigationState} from '@react-navigation/native' 6 + 7 + import {emitSoftReset} from '#/state/events' 9 8 import {usePinnedFeedsInfos} from '#/state/queries/feed' 10 9 import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' 11 - import {FeedDescriptor} from '#/state/queries/post-feed' 10 + import {usePalette} from 'lib/hooks/usePalette' 11 + import {getCurrentRoute} from 'lib/routes/helpers' 12 12 import {NavigationProp} from 'lib/routes/types' 13 - import {emitSoftReset} from '#/state/events' 13 + import {TextLink} from 'view/com/util/Link' 14 14 15 15 export function DesktopFeeds() { 16 16 const pal = usePalette('default') ··· 31 31 return ( 32 32 <View style={[styles.container, pal.view]}> 33 33 {pinnedFeedInfos.map(feedInfo => { 34 - const uri = feedInfo.uri 35 - let feed: FeedDescriptor 36 - if (!uri) { 37 - feed = 'home' 38 - } else if (uri.includes('app.bsky.feed.generator')) { 39 - feed = `feedgen|${uri}` 40 - } else if (uri.includes('app.bsky.graph.list')) { 41 - feed = `list|${uri}` 42 - } else { 43 - return null 44 - } 34 + const feed = feedInfo.feedDescriptor 45 35 return ( 46 36 <FeedItem 47 37 key={feed}
+4 -4
yarn.lock
··· 58 58 multiformats "^9.9.0" 59 59 tlds "^1.234.0" 60 60 61 - "@atproto/api@^0.12.5": 62 - version "0.12.5" 63 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.5.tgz#3ed70990b27c468d9663ca71306039cab663ca96" 64 - integrity sha512-xqdl/KrAK2kW6hN8+eSmKTWHgMNaPnDAEvZzo08Xbk/5jdRzjoEPS+p7k/wQ+ZefwOHL3QUbVPO4hMfmVxzO/Q== 61 + "@atproto/api@^0.12.6": 62 + version "0.12.6" 63 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.6.tgz#690c004c5ac7fc7bceac4605d8c1ec1f580be270" 64 + integrity sha512-30htXN2Hjl1jzzeAtIhggOsVS4vA975pMUQYoA4xMonug+z6O9NHcka3yYb4C9ldpnGugvRPKH7EhAUbiDTC5w== 65 65 dependencies: 66 66 "@atproto/common-web" "^0.3.0" 67 67 "@atproto/lexicon" "^0.4.0"