diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx
index 6364d332..21a2b9fb 100644
--- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx
+++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx
@@ -1,5 +1,5 @@
-import {requireNativeViewManager} from 'expo-modules-core'
import * as React from 'react'
+import {requireNativeViewManager} from 'expo-modules-core'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
diff --git a/package.json b/package.json
index 26d6b061..516428f8 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,7 @@
},
"dependencies": {
"@atproto-labs/api": "^0.12.8-clipclops.0",
- "@atproto/api": "^0.12.5",
+ "@atproto/api": "^0.12.6",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
index 2bb7ed59..542f2d29 100644
--- a/src/components/LabelingServiceCard/index.tsx
+++ b/src/components/LabelingServiceCard/index.tsx
@@ -1,18 +1,18 @@
import React from 'react'
import {View} from 'react-native'
+import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {AppBskyLabelerDefs} from '@atproto/api'
import {getLabelingServiceTitle} from '#/lib/moderation'
-import {Link as InternalLink, LinkProps} from '#/components/Link'
-import {Text} from '#/components/Typography'
-import {useLabelerInfoQuery} from '#/state/queries/labeler'
-import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
-import {RichText} from '#/components/RichText'
-import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
import {sanitizeHandle} from '#/lib/strings/handles'
+import {useLabelerInfoQuery} from '#/state/queries/labeler'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
type LabelingServiceProps = {
labeler: AppBskyLabelerDefs.LabelerViewDetailed
diff --git a/src/components/hooks/useHeaderOffset.ts b/src/components/hooks/useHeaderOffset.ts
new file mode 100644
index 00000000..e2290c04
--- /dev/null
+++ b/src/components/hooks/useHeaderOffset.ts
@@ -0,0 +1,16 @@
+import {useWindowDimensions} from 'react-native'
+
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+
+export function useHeaderOffset() {
+ const {isDesktop, isTablet} = useWebMediaQueries()
+ const {fontScale} = useWindowDimensions()
+ if (isDesktop || isTablet) {
+ return 0
+ }
+ const navBarHeight = 42
+ const tabBarPad = 10 + 10 + 3 // padding + border
+ const normalLineHeight = 1.2
+ const tabBarText = 16 * normalLineHeight * fontScale
+ return navBarHeight + tabBarPad + tabBarText
+}
diff --git a/src/components/icons/Home.tsx b/src/components/icons/Home.tsx
new file mode 100644
index 00000000..e150b7b8
--- /dev/null
+++ b/src/components/icons/Home.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Home_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ 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',
+})
+
+export const Home_Filled_Corner0_Rounded = createSinglePathSVG({
+ 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',
+})
diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx
index 46825d76..ea5c74f9 100644
--- a/src/components/moderation/LabelsOnMe.tsx
+++ b/src/components/moderation/LabelsOnMe.tsx
@@ -3,10 +3,10 @@ import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
import {msg, Plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useSession} from '#/state/session'
+import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
-import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button'
+import {Button, ButtonIcon, ButtonSize, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {
LabelsOnMeDialog,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index d7bec1e1..83f51391 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,4 +1,5 @@
import {Insets, Platform} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
@@ -44,7 +45,7 @@ export function IS_TEST_USER(handle?: string) {
}
export function IS_PROD_SERVICE(url?: string) {
- return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
+ return url && url !== STAGING_SERVICE && !url.startsWith(LOCAL_DEV_SERVICE)
}
export const PROD_DEFAULT_FEED = (rkey: string) =>
@@ -92,6 +93,24 @@ export const BSKY_FEED_OWNER_DIDS = [
'did:plc:q6gjnaw2blty4crticxkmujt',
]
+export const DISCOVER_FEED_URI =
+ 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
+export const DISCOVER_SAVED_FEED = {
+ type: 'feed',
+ value: DISCOVER_FEED_URI,
+ pinned: true,
+}
+export const TIMELINE_SAVED_FEED = {
+ type: 'timeline',
+ value: 'following',
+ pinned: true,
+}
+
+export const RECOMMENDED_SAVED_FEEDS: Pick<
+ AppBskyActorDefs.SavedFeed,
+ 'type' | 'value' | 'pinned'
+>[] = [DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED]
+
export const GIF_SERVICE = 'https://gifs.bsky.app'
export const GIF_SEARCH = (params: string) =>
diff --git a/src/screens/Feeds/NoFollowingFeed.tsx b/src/screens/Feeds/NoFollowingFeed.tsx
new file mode 100644
index 00000000..03ced8eb
--- /dev/null
+++ b/src/screens/Feeds/NoFollowingFeed.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {TIMELINE_SAVED_FEED} from '#/lib/constants'
+import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function NoFollowingFeed() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {mutateAsync: addSavedFeeds} = useAddSavedFeedsMutation()
+
+ const addRecommendedFeeds = React.useCallback(
+ (e: any) => {
+ e.preventDefault()
+
+ addSavedFeeds([
+ {
+ ...TIMELINE_SAVED_FEED,
+ pinned: true,
+ },
+ ])
+
+ // prevent navigation
+ return false
+ },
+ [addSavedFeeds],
+ )
+
+ return (
+
+
+ Looks like you're missing a following feed.{' '}
+
+
+
+ Click here to add one.
+
+
+ )
+}
diff --git a/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx b/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx
new file mode 100644
index 00000000..8f6bd9d2
--- /dev/null
+++ b/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx
@@ -0,0 +1,57 @@
+import React from 'react'
+import {View} from 'react-native'
+import {TID} from '@atproto/common-web'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {RECOMMENDED_SAVED_FEEDS} from '#/lib/constants'
+import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Text} from '#/components/Typography'
+
+/**
+ * Explicitly named, since the CTA in this component will overwrite all saved
+ * feeds if pressed. It should only be presented to the user if they actually
+ * have no other feeds saved.
+ */
+export function NoSavedFeedsOfAnyType() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {isPending, mutateAsync: overwriteSavedFeeds} =
+ useOverwriteSavedFeedsMutation()
+
+ const addRecommendedFeeds = React.useCallback(async () => {
+ await overwriteSavedFeeds(
+ RECOMMENDED_SAVED_FEEDS.map(f => ({
+ ...f,
+ id: TID.nextStr(),
+ })),
+ )
+ }, [overwriteSavedFeeds])
+
+ return (
+
+
+
+ Looks like you haven't saved any feeds! Use our recommendations or
+ browse more below.
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Home/NoFeedsPinned.tsx b/src/screens/Home/NoFeedsPinned.tsx
new file mode 100644
index 00000000..e804e3e0
--- /dev/null
+++ b/src/screens/Home/NoFeedsPinned.tsx
@@ -0,0 +1,129 @@
+import React from 'react'
+import {View} from 'react-native'
+import {TID} from '@atproto/common-web'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants'
+import {isNative} from '#/platform/detection'
+import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences'
+import {UsePreferencesQueryResponse} from '#/state/queries/preferences'
+import {NavigationProp} from 'lib/routes/types'
+import {CenteredView} from '#/view/com/util/Views'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
+import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function NoFeedsPinned({
+ preferences,
+}: {
+ preferences: UsePreferencesQueryResponse
+}) {
+ const {_} = useLingui()
+ const headerOffset = useHeaderOffset()
+ const navigation = useNavigation()
+ const {isPending, mutateAsync: overwriteSavedFeeds} =
+ useOverwriteSavedFeedsMutation()
+
+ const addRecommendedFeeds = React.useCallback(async () => {
+ let skippedTimeline = false
+ let skippedDiscover = false
+ let remainingSavedFeeds = []
+
+ // remove first instance of both timeline and discover, since we're going to overwrite them
+ for (const savedFeed of preferences.savedFeeds) {
+ if (savedFeed.type === 'timeline' && !skippedTimeline) {
+ skippedTimeline = true
+ } else if (
+ savedFeed.value === DISCOVER_SAVED_FEED.value &&
+ !skippedDiscover
+ ) {
+ skippedDiscover = true
+ } else {
+ remainingSavedFeeds.push(savedFeed)
+ }
+ }
+
+ const toSave = [
+ {
+ ...DISCOVER_SAVED_FEED,
+ pinned: true,
+ id: TID.nextStr(),
+ },
+ {
+ ...TIMELINE_SAVED_FEED,
+ pinned: true,
+ id: TID.nextStr(),
+ },
+ ...remainingSavedFeeds,
+ ]
+
+ await overwriteSavedFeeds(toSave)
+ }, [overwriteSavedFeeds, preferences.savedFeeds])
+
+ const onPressFeedsLink = React.useCallback(() => {
+ if (isNative) {
+ // Hack that's necessary due to how our navigators are set up.
+ navigation.navigate('FeedsTab')
+ navigation.popToTop()
+ return false
+ }
+ }, [navigation])
+
+ return (
+
+
+
+
+ Whoops!
+
+
+
+ Looks like you unpinned all your feeds. But don't worry, you can
+ add some below 😄
+
+
+
+
+
+
+
+
+
+ {_(msg`Browse other feeds`)}
+
+
+
+
+ )
+}
diff --git a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
index d2b2a5f3..0aa063fa 100644
--- a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
+++ b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
-import {Trans, msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index e7054fb1..4cc611ef 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -1,13 +1,15 @@
import React from 'react'
import {View} from 'react-native'
+import {TID} from '@atproto/common-web'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
-import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
+import {BSKY_APP_ACCOUNT_DID, IS_PROD_SERVICE} from '#/lib/constants'
+import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
-import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
+import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
@@ -37,7 +39,7 @@ export function StepFinished() {
const {state, dispatch} = React.useContext(Context)
const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = React.useState(false)
- const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation()
+ const {mutateAsync: overwriteSavedFeeds} = useOverwriteSavedFeedsMutation()
const {getAgent} = useAgent()
const finishOnboarding = React.useCallback(async () => {
@@ -64,10 +66,41 @@ export function StepFinished() {
// these must be serial
(async () => {
await getAgent().setInterestsPref({tags: selectedInterests})
- await saveFeeds({
- saved: selectedFeeds,
- pinned: selectedFeeds,
- })
+
+ // TODO: In the reduced onboarding, we'll want to exit early here.
+
+ const otherFeeds = selectedFeeds.length
+ ? selectedFeeds.map(f => ({
+ type: 'feed',
+ value: f,
+ pinned: true,
+ id: TID.nextStr(),
+ }))
+ : []
+
+ /*
+ * If no selected feeds and we're in prod, add the discover feed
+ * (mimics old behavior)
+ */
+ if (
+ IS_PROD_SERVICE(getAgent().service.toString()) &&
+ !otherFeeds.length
+ ) {
+ otherFeeds.push({
+ ...DISCOVER_SAVED_FEED,
+ pinned: true,
+ id: TID.nextStr(),
+ })
+ }
+
+ await overwriteSavedFeeds([
+ {
+ ...TIMELINE_SAVED_FEED,
+ pinned: true,
+ id: TID.nextStr(),
+ },
+ ...otherFeeds,
+ ])
})(),
])
} catch (e: any) {
@@ -82,7 +115,15 @@ export function StepFinished() {
track('OnboardingV2:StepFinished:End')
track('OnboardingV2:Complete')
logEvent('onboarding:finished:nextPressed', {})
- }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track, getAgent])
+ }, [
+ state,
+ dispatch,
+ onboardDispatch,
+ setSaving,
+ overwriteSavedFeeds,
+ track,
+ getAgent,
+ ])
React.useEffect(() => {
track('OnboardingV2:StepFinished:Start')
diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx
index c4954d20..ac129d17 100644
--- a/src/state/preferences/feed-tuners.tsx
+++ b/src/state/preferences/feed-tuners.tsx
@@ -1,9 +1,10 @@
import {useMemo} from 'react'
+
import {FeedTuner} from '#/lib/api/feed-manip'
import {FeedDescriptor} from '../queries/post-feed'
-import {useLanguagePrefs} from './languages'
import {usePreferencesQuery} from '../queries/preferences'
import {useSession} from '../session'
+import {useLanguagePrefs} from './languages'
export function useFeedTuners(feedDesc: FeedDescriptor) {
const langPrefs = useLanguagePrefs()
@@ -20,7 +21,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (feedDesc.startsWith('list')) {
return [FeedTuner.dedupReposts]
}
- if (feedDesc === 'home' || feedDesc === 'following') {
+ if (feedDesc === 'following') {
const feedTuners = []
if (preferences?.feedViewPrefs.hideReposts) {
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 1741d113..19cded08 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -1,4 +1,5 @@
import {
+ AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyGraphDefs,
AppBskyUnspeccedGetPopularFeedGenerators,
@@ -13,16 +14,19 @@ import {
useQuery,
} from '@tanstack/react-query'
+import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
import {router} from '#/routes'
+import {FeedDescriptor} from './post-feed'
export type FeedSourceFeedInfo = {
type: 'feed'
uri: string
+ feedDescriptor: FeedDescriptor
route: {
href: string
name: string
@@ -41,6 +45,7 @@ export type FeedSourceFeedInfo = {
export type FeedSourceListInfo = {
type: 'list'
uri: string
+ feedDescriptor: FeedDescriptor
route: {
href: string
name: string
@@ -79,6 +84,7 @@ export function hydrateFeedGenerator(
return {
type: 'feed',
uri: view.uri,
+ feedDescriptor: `feedgen|${view.uri}`,
cid: view.cid,
route: {
href,
@@ -110,6 +116,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
return {
type: 'list',
uri: view.uri,
+ feedDescriptor: `list|${view.uri}`,
route: {
href,
name: route[0],
@@ -202,27 +209,15 @@ export function useSearchPopularFeedsMutation() {
})
}
-const FOLLOWING_FEED_STUB: FeedSourceInfo = {
- type: 'feed',
- displayName: 'Following',
- uri: '',
- route: {
- href: '/',
- name: 'Home',
- params: {},
- },
- cid: '',
- avatar: '',
- description: new RichText({text: ''}),
- creatorDid: '',
- creatorHandle: '',
- likeCount: 0,
- likeUri: '',
+export type SavedFeedSourceInfo = FeedSourceInfo & {
+ savedFeed: AppBskyActorDefs.SavedFeed
}
-const DISCOVER_FEED_STUB: FeedSourceInfo = {
+
+const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
type: 'feed',
displayName: 'Discover',
- uri: '',
+ uri: DISCOVER_FEED_URI,
+ feedDescriptor: `feedgen|${DISCOVER_FEED_URI}`,
route: {
href: '/',
name: 'Home',
@@ -235,6 +230,11 @@ const DISCOVER_FEED_STUB: FeedSourceInfo = {
creatorHandle: '',
likeCount: 0,
likeUri: '',
+ // ---
+ savedFeed: {
+ id: 'pwi-discover',
+ ...DISCOVER_SAVED_FEED,
+ },
}
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
@@ -243,43 +243,45 @@ export function usePinnedFeedsInfos() {
const {hasSession} = useSession()
const {getAgent} = useAgent()
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
- const pinnedUris = preferences?.feeds?.pinned ?? []
+ const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? []
return useQuery({
staleTime: STALE.INFINITY,
enabled: !isLoadingPrefs,
queryKey: [
pinnedFeedInfosQueryKeyRoot,
- (hasSession ? 'authed:' : 'unauthed:') + pinnedUris.join(','),
+ (hasSession ? 'authed:' : 'unauthed:') +
+ pinnedItems.map(f => f.value).join(','),
],
queryFn: async () => {
- let resolved = new Map()
+ if (!hasSession) {
+ return [PWI_DISCOVER_FEED_STUB]
+ }
+
+ let resolved = new Map()
// Get all feeds. We can do this in a batch.
- const feedUris = pinnedUris.filter(
- uri => getFeedTypeFromUri(uri) === 'feed',
- )
+ const pinnedFeeds = pinnedItems.filter(feed => feed.type === 'feed')
let feedsPromise = Promise.resolve()
- if (feedUris.length > 0) {
+ if (pinnedFeeds.length > 0) {
feedsPromise = getAgent()
.app.bsky.feed.getFeedGenerators({
- feeds: feedUris,
+ feeds: pinnedFeeds.map(f => f.value),
})
.then(res => {
- for (let feedView of res.data.feeds) {
+ for (let i = 0; i < res.data.feeds.length; i++) {
+ const feedView = res.data.feeds[i]
resolved.set(feedView.uri, hydrateFeedGenerator(feedView))
}
})
}
// Get all lists. This currently has to be done individually.
- const listUris = pinnedUris.filter(
- uri => getFeedTypeFromUri(uri) === 'list',
- )
- const listsPromises = listUris.map(listUri =>
+ const pinnedLists = pinnedItems.filter(feed => feed.type === 'list')
+ const listsPromises = pinnedLists.map(list =>
getAgent()
.app.bsky.graph.getList({
- list: listUri,
+ list: list.value,
limit: 1,
})
.then(res => {
@@ -288,12 +290,37 @@ export function usePinnedFeedsInfos() {
}),
)
- // The returned result will have the original order.
- const result = [hasSession ? FOLLOWING_FEED_STUB : DISCOVER_FEED_STUB]
await Promise.allSettled([feedsPromise, ...listsPromises])
- for (let pinnedUri of pinnedUris) {
- if (resolved.has(pinnedUri)) {
- result.push(resolved.get(pinnedUri))
+
+ // order the feeds/lists in the order they were pinned
+ const result: SavedFeedSourceInfo[] = []
+ for (let pinnedItem of pinnedItems) {
+ const feedInfo = resolved.get(pinnedItem.value)
+ if (feedInfo) {
+ result.push({
+ ...feedInfo,
+ savedFeed: pinnedItem,
+ })
+ } else if (pinnedItem.type === 'timeline') {
+ result.push({
+ type: 'feed',
+ displayName: 'Following',
+ uri: pinnedItem.value,
+ feedDescriptor: 'following',
+ route: {
+ href: '/',
+ name: 'Home',
+ params: {},
+ },
+ cid: '',
+ avatar: '',
+ description: new RichText({text: ''}),
+ creatorDid: '',
+ creatorHandle: '',
+ likeCount: 0,
+ likeUri: '',
+ savedFeed: pinnedItem,
+ })
}
}
return result
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index dc86a9ba..7b312edf 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -44,8 +44,8 @@ type AuthorFilter =
| 'posts_with_media'
type FeedUri = string
type ListUri = string
+
export type FeedDescriptor =
- | 'home'
| 'following'
| `author|${ActorDid}|${AuthorFilter}`
| `feedgen|${FeedUri}`
@@ -390,7 +390,7 @@ function createApi({
userInterests?: string
getAgent: () => BskyAgent
}) {
- if (feedDesc === 'home') {
+ if (feedDesc === 'following') {
if (feedParams.mergeFeedEnabled) {
return new MergeFeedAPI({
getAgent,
@@ -401,8 +401,6 @@ function createApi({
} else {
return new HomeFeedAPI({getAgent, userInterests})
}
- } else if (feedDesc === 'following') {
- return new FollowingFeedAPI({getAgent})
} else if (feedDesc.startsWith('author')) {
const [_, actor, filter] = feedDesc.split('|')
return new AuthorFeedAPI({getAgent, feedParams: {actor, filter}})
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 4cb4d1e9..d94edb47 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -1,8 +1,8 @@
-import {
- UsePreferencesQueryResponse,
- ThreadViewPreferences,
-} from '#/state/queries/preferences/types'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
+import {
+ ThreadViewPreferences,
+ UsePreferencesQueryResponse,
+} from '#/state/queries/preferences/types'
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{
@@ -20,20 +20,8 @@ export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = {
lab_treeViewEnabled: false,
}
-const DEFAULT_PROD_FEED_PREFIX = (rkey: string) =>
- `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
-export const DEFAULT_PROD_FEEDS = {
- pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
- saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
-}
-
export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
birthDate: new Date('2022-11-17'), // TODO(pwi)
- feeds: {
- saved: [],
- pinned: [],
- unpinned: [],
- },
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
@@ -45,4 +33,5 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi)
interests: {tags: []},
+ savedFeeds: [],
}
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index f51eaac2..b3d2fa9e 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -51,14 +51,11 @@ export function usePreferencesQuery() {
const preferences: UsePreferencesQueryResponse = {
...res,
- feeds: {
- saved: res.feeds?.saved || [],
- pinned: res.feeds?.pinned || [],
- unpinned:
- res.feeds.saved?.filter(f => {
- return !res.feeds.pinned?.includes(f)
- }) || [],
- },
+ savedFeeds: res.savedFeeds.filter(f => f.type !== 'unknown'),
+ /**
+ * Special preference, only used for following feed, previously
+ * called `home`
+ */
feedViewPrefs: {
...DEFAULT_HOME_FEED_PREFS,
...(res.feedViewPrefs.home || {}),
@@ -168,6 +165,10 @@ export function useSetFeedViewPreferencesMutation() {
return useMutation>({
mutationFn: async prefs => {
+ /*
+ * special handling here, merged into `feedViewPrefs` above, since
+ * following was previously called `home`
+ */
await getAgent().setFeedViewPrefs('home', prefs)
// triggers a refetch
await queryClient.invalidateQueries({
@@ -192,17 +193,13 @@ export function useSetThreadViewPreferencesMutation() {
})
}
-export function useSetSaveFeedsMutation() {
+export function useOverwriteSavedFeedsMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
- return useMutation<
- void,
- unknown,
- Pick
- >({
- mutationFn: async ({saved, pinned}) => {
- await getAgent().setSavedFeeds(saved, pinned)
+ return useMutation({
+ mutationFn: async savedFeeds => {
+ await getAgent().overwriteSavedFeeds(savedFeeds)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
@@ -211,13 +208,17 @@ export function useSetSaveFeedsMutation() {
})
}
-export function useSaveFeedMutation() {
+export function useAddSavedFeedsMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
- return useMutation({
- mutationFn: async ({uri}) => {
- await getAgent().addSavedFeed(uri)
+ return useMutation<
+ void,
+ unknown,
+ Pick[]
+ >({
+ mutationFn: async savedFeeds => {
+ await getAgent().addSavedFeeds(savedFeeds)
track('CustomFeed:Save')
// triggers a refetch
await queryClient.invalidateQueries({
@@ -231,9 +232,9 @@ export function useRemoveFeedMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
- return useMutation({
- mutationFn: async ({uri}) => {
- await getAgent().removeSavedFeed(uri)
+ return useMutation>({
+ mutationFn: async savedFeed => {
+ await getAgent().removeSavedFeeds([savedFeed.id])
track('CustomFeed:Unsave')
// triggers a refetch
await queryClient.invalidateQueries({
@@ -243,30 +244,14 @@ export function useRemoveFeedMutation() {
})
}
-export function usePinFeedMutation() {
+export function useUpdateSavedFeedsMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
- return useMutation({
- mutationFn: async ({uri}) => {
- await getAgent().addPinnedFeed(uri)
- track('CustomFeed:Pin', {uri})
- // triggers a refetch
- await queryClient.invalidateQueries({
- queryKey: preferencesQueryKey,
- })
- },
- })
-}
+ return useMutation({
+ mutationFn: async feeds => {
+ await getAgent().updateSavedFeeds(feeds)
-export function useUnpinFeedMutation() {
- const queryClient = useQueryClient()
- const {getAgent} = useAgent()
-
- return useMutation({
- mutationFn: async ({uri}) => {
- await getAgent().removePinnedFeed(uri)
- track('CustomFeed:Unpin', {uri})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts
index 96da16f1..928bb90d 100644
--- a/src/state/queries/preferences/types.ts
+++ b/src/state/queries/preferences/types.ts
@@ -1,7 +1,7 @@
import {
+ BskyFeedViewPreference,
BskyPreferences,
BskyThreadViewPreference,
- BskyFeedViewPreference,
} from '@atproto/api'
export type UsePreferencesQueryResponse = Omit<
@@ -16,9 +16,6 @@ export type UsePreferencesQueryResponse = Omit<
*/
threadViewPrefs: ThreadViewPreferences
userAge: number | undefined
- feeds: Required & {
- unpinned: string[]
- }
}
export type ThreadViewPreferences = Pick<
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 024f6e7d..9633dc0e 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -1,10 +1,15 @@
import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
+import {TID} from '@atproto/common-web'
import {networkRetry} from '#/lib/async/retry'
-import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
-import {IS_PROD_SERVICE} from '#/lib/constants'
+import {
+ DISCOVER_SAVED_FEED,
+ IS_PROD_SERVICE,
+ PUBLIC_BSKY_SERVICE,
+ TIMELINE_SAVED_FEED,
+} from '#/lib/constants'
import {tryFetchGates} from '#/lib/statsig/statsig'
-import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
+import {logger} from '#/logger'
import {
configureModerationForAccount,
configureModerationForGuest,
@@ -134,9 +139,28 @@ export async function createAgentAndCreateAccount(
// Not awaited so that we can still get into onboarding.
// This is OK because we won't let you toggle adult stuff until you set the date.
- agent.setPersonalDetails({birthDate: birthDate.toISOString()})
if (IS_PROD_SERVICE(service)) {
- agent.setSavedFeeds(DEFAULT_PROD_FEEDS.saved, DEFAULT_PROD_FEEDS.pinned)
+ try {
+ networkRetry(1, async () => {
+ await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
+ await agent.overwriteSavedFeeds([
+ {
+ ...DISCOVER_SAVED_FEED,
+ id: TID.nextStr(),
+ },
+ {
+ ...TIMELINE_SAVED_FEED,
+ id: TID.nextStr(),
+ },
+ ])
+ })
+ } catch (e: any) {
+ logger.error(e, {
+ context: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
+ })
+ }
+ } else {
+ agent.setPersonalDetails({birthDate: birthDate.toISOString()})
}
return prepareAgent(agent, gates, moderation, onSessionChange)
diff --git a/src/state/shell/selected-feed.tsx b/src/state/shell/selected-feed.tsx
index df50b395..08b7ba77 100644
--- a/src/state/shell/selected-feed.tsx
+++ b/src/state/shell/selected-feed.tsx
@@ -1,47 +1,46 @@
import React from 'react'
-import {Gate} from '#/lib/statsig/gates'
-import {useGate} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
+import {FeedDescriptor} from '#/state/queries/post-feed'
-type StateContext = string
-type SetContext = (v: string) => void
+type StateContext = FeedDescriptor | null
+type SetContext = (v: FeedDescriptor) => void
-const stateContext = React.createContext('home')
+const stateContext = React.createContext(null)
const setContext = React.createContext((_: string) => {})
-function getInitialFeed(gate: (gateName: Gate) => boolean) {
+function getInitialFeed(): FeedDescriptor | null {
if (isWeb) {
if (window.location.pathname === '/') {
const params = new URLSearchParams(window.location.search)
const feedFromUrl = params.get('feed')
if (feedFromUrl) {
// If explicitly booted from a link like /?feed=..., prefer that.
- return feedFromUrl
+ return feedFromUrl as FeedDescriptor
}
}
+
const feedFromSession = sessionStorage.getItem('lastSelectedHomeFeed')
if (feedFromSession) {
// Fall back to a previously chosen feed for this browser tab.
- return feedFromSession
+ return feedFromSession as FeedDescriptor
}
}
- if (!gate('start_session_with_following_v2')) {
- const feedFromPersisted = persisted.get('lastSelectedHomeFeed')
- if (feedFromPersisted) {
- // Fall back to the last chosen one across all tabs.
- return feedFromPersisted
- }
+
+ const feedFromPersisted = persisted.get('lastSelectedHomeFeed')
+ if (feedFromPersisted) {
+ // Fall back to the last chosen one across all tabs.
+ return feedFromPersisted as FeedDescriptor
}
- return 'home'
+
+ return null
}
export function Provider({children}: React.PropsWithChildren<{}>) {
- const gate = useGate()
- const [state, setState] = React.useState(() => getInitialFeed(gate))
+ const [state, setState] = React.useState(() => getInitialFeed())
- const saveState = React.useCallback((feed: string) => {
+ const saveState = React.useCallback((feed: FeedDescriptor) => {
setState(feed)
if (isWeb) {
try {
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index bb782809..6a9fc934 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -1,5 +1,6 @@
import React from 'react'
-import {useWindowDimensions, View} from 'react-native'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -17,9 +18,9 @@ import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {ComposeIcon2} from 'lib/icons'
import {s} from 'lib/styles'
+import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
import {Feed} from '../posts/Feed'
import {FAB} from '../util/fab/FAB'
import {ListMethods} from '../util/List'
@@ -35,6 +36,7 @@ export function FeedPage({
feedParams,
renderEmptyState,
renderEndOfFeed,
+ savedFeedConfig,
}: {
testID?: string
feed: FeedDescriptor
@@ -42,6 +44,7 @@ export function FeedPage({
isPageFocused: boolean
renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
+ savedFeedConfig?: AppBskyActorDefs.SavedFeed
}) {
const {hasSession} = useSession()
const {_} = useLingui()
@@ -129,6 +132,7 @@ export function FeedPage({
renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
headerOffset={headerOffset}
+ savedFeedConfig={savedFeedConfig}
/>
@@ -153,16 +157,3 @@ export function FeedPage({
)
}
-
-function useHeaderOffset() {
- const {isDesktop, isTablet} = useWebMediaQueries()
- const {fontScale} = useWindowDimensions()
- if (isDesktop || isTablet) {
- return 0
- }
- const navBarHeight = 42
- const tabBarPad = 10 + 10 + 3 // padding + border
- const normalLineHeight = 1.2
- const tabBarText = 16 * normalLineHeight * fontScale
- return navBarHeight + tabBarPad + tabBarText
-}
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 8a21d86a..bb536bcc 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -1,29 +1,30 @@
import React from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../util/text/Text'
-import {RichText} from '#/components/RichText'
-import {usePalette} from 'lib/hooks/usePalette'
-import {s} from 'lib/styles'
-import {UserAvatar} from '../util/UserAvatar'
import {AtUri} from '@atproto/api'
-import * as Toast from 'view/com/util/Toast'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {logger} from '#/logger'
-import {Trans, msg, Plural} from '@lingui/macro'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {
- usePinFeedMutation,
- UsePreferencesQueryResponse,
+ useAddSavedFeedsMutation,
usePreferencesQuery,
- useSaveFeedMutation,
+ UsePreferencesQueryResponse,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
-import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
-import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import {useTheme} from '#/alf'
-import * as Prompt from '#/components/Prompt'
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
+import {usePalette} from 'lib/hooks/usePalette'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {s} from 'lib/styles'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import * as Toast from 'view/com/util/Toast'
+import {useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import * as Prompt from '#/components/Prompt'
+import {RichText} from '#/components/RichText'
+import {Text} from '../util/text/Text'
+import {UserAvatar} from '../util/UserAvatar'
export function FeedSourceCard({
feedUri,
@@ -87,53 +88,54 @@ export function FeedSourceCardLoaded({
const removePromptControl = Prompt.usePromptControl()
const navigation = useNavigationDeduped()
- const {isPending: isSavePending, mutateAsync: saveFeed} =
- useSaveFeedMutation()
+ const {isPending: isAddSavedFeedPending, mutateAsync: addSavedFeeds} =
+ useAddSavedFeedsMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
- const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
- const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || ''))
+ const savedFeedConfig = preferences?.savedFeeds?.find(
+ f => f.value === feed?.uri,
+ )
+ const isSaved = Boolean(savedFeedConfig)
const onSave = React.useCallback(async () => {
- if (!feed) return
+ if (!feed || isSaved) return
try {
- if (pinOnSave) {
- await pinFeed({uri: feed.uri})
- } else {
- await saveFeed({uri: feed.uri})
- }
+ await addSavedFeeds([
+ {
+ type: 'feed',
+ value: feed.uri,
+ pinned: pinOnSave,
+ },
+ ])
Toast.show(_(msg`Added to my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to save feed', {message: e})
}
- }, [_, feed, pinFeed, pinOnSave, saveFeed])
+ }, [_, feed, pinOnSave, addSavedFeeds, isSaved])
const onUnsave = React.useCallback(async () => {
- if (!feed) return
+ if (!savedFeedConfig) return
try {
- await removeFeed({uri: feed.uri})
+ await removeFeed(savedFeedConfig)
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {message: e})
}
- }, [_, feed, removeFeed])
+ }, [_, removeFeed, savedFeedConfig])
const onToggleSaved = React.useCallback(async () => {
- // Only feeds can be un/saved, lists are handled elsewhere
- if (feed?.type !== 'feed') return
-
if (isSaved) {
removePromptControl.open()
} else {
await onSave()
}
- }, [feed?.type, isSaved, removePromptControl, onSave])
+ }, [isSaved, removePromptControl, onSave])
/*
* LOAD STATE
@@ -204,7 +206,7 @@ export function FeedSourceCardLoaded({
}
}}
key={feed.uri}>
-
+
@@ -221,11 +223,11 @@ export function FeedSourceCardLoaded({
- {showSaveBtn && feed.type === 'feed' && (
+ {showSaveBtn && (
()
const pal = usePalette('default')
const hasPinnedCustom = React.useMemo(() => {
- return feeds.some(tab => tab.uri !== '')
- }, [feeds])
+ if (!hasSession) return false
+ return feeds.some(tab => {
+ const isFollowing = tab.uri === 'following'
+ return !isFollowing
+ })
+ }, [feeds, hasSession])
const items = React.useMemo(() => {
const pinnedNames = feeds.map(f => f.displayName)
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index fd4c486a..a95a9483 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -1,22 +1,23 @@
import React from 'react'
import {LayoutAnimation, StyleSheet, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import ImageView from './ImageViewing'
-import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
-import * as Toast from '../util/Toast'
-import {Text} from '../util/text/Text'
-import {s, colors} from 'lib/styles'
-import {Button} from '../util/forms/Button'
-import {isIOS} from 'platform/detection'
import * as MediaLibrary from 'expo-media-library'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
import {
+ ImagesLightbox,
+ ProfileImageLightbox,
useLightbox,
useLightboxControls,
- ProfileImageLightbox,
- ImagesLightbox,
} from '#/state/lightbox'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
+import {saveImageToMediaLibrary, shareImageModal} from 'lib/media/manip'
+import {colors, s} from 'lib/styles'
+import {isIOS} from 'platform/detection'
+import {Button} from '../util/forms/Button'
+import {Text} from '../util/text/Text'
+import * as Toast from '../util/Toast'
+import ImageView from './ImageViewing'
export function Lightbox() {
const {activeLightbox} = useLightbox()
diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx
index 2b83c7a9..ce3fbcef 100644
--- a/src/view/com/modals/SelfLabel.tsx
+++ b/src/view/com/modals/SelfLabel.tsx
@@ -1,16 +1,17 @@
import React, {useState} from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {Text} from '../util/text/Text'
-import {s, colors} from 'lib/styles'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useModalControls} from '#/state/modals'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {colors, s} from 'lib/styles'
import {isWeb} from 'platform/detection'
+import {ScrollView} from 'view/com/modals/util'
import {Button} from '../util/forms/Button'
import {SelectableBtn} from '../util/forms/SelectableBtn'
-import {ScrollView} from 'view/com/modals/util'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
+import {Text} from '../util/text/Text'
const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index ff8acd60..5791e26a 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -1,11 +1,12 @@
-import React, {useRef, useMemo, useEffect, useState, useCallback} from 'react'
-import {StyleSheet, View, ScrollView, LayoutChangeEvent} from 'react-native'
-import {Text} from '../util/text/Text'
-import {PressableWithHover} from '../util/PressableWithHover'
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
+import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
+
+import {isNative} from '#/platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {PressableWithHover} from '../util/PressableWithHover'
+import {Text} from '../util/text/Text'
import {DraggableScrollView} from './DraggableScrollView'
-import {isNative} from '#/platform/detection'
export interface TabBarProps {
testID?: string
@@ -139,7 +140,10 @@ export function TabBar({
+ style={[
+ selected ? pal.text : pal.textLight,
+ {lineHeight: 20},
+ ]}>
{item}
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 8969f7cd..c51733d1 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -8,6 +8,7 @@ import {
View,
ViewStyle,
} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -64,6 +65,7 @@ let Feed = ({
desktopFixedHeightOffset,
ListHeaderComponent,
extraData,
+ savedFeedConfig,
}: {
feed: FeedDescriptor
feedParams?: FeedParams
@@ -82,6 +84,7 @@ let Feed = ({
desktopFixedHeightOffset?: number
ListHeaderComponent?: () => JSX.Element
extraData?: any
+ savedFeedConfig?: AppBskyActorDefs.SavedFeed
}): React.ReactNode => {
const theme = useTheme()
const {track} = useAnalytics()
@@ -140,7 +143,6 @@ let Feed = ({
if (
data?.pages.length === 1 &&
(feed === 'following' ||
- feed === 'home' ||
feed === `author|${myDid}|posts_and_author_threads`)
) {
queryClient.invalidateQueries({queryKey: RQKEY(feed)})
@@ -280,6 +282,7 @@ let Feed = ({
feedDesc={feed}
error={error ?? undefined}
onPressTryAgain={onPressTryAgain}
+ savedFeedConfig={savedFeedConfig}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
@@ -302,7 +305,15 @@ let Feed = ({
}
return
},
- [feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState, _],
+ [
+ feed,
+ error,
+ onPressTryAgain,
+ onPressRetryLoadMore,
+ renderEmptyState,
+ _,
+ savedFeedConfig,
+ ],
)
const shouldRenderEndOfFeed =
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index d4ca38d0..a152bc90 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -1,21 +1,22 @@
import React from 'react'
import {View} from 'react-native'
-import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api'
-import {Text} from '../util/text/Text'
-import {Button} from '../util/forms/Button'
-import * as Toast from '../util/Toast'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useNavigation} from '@react-navigation/native'
-import {NavigationProp} from 'lib/routes/types'
-import {logger} from '#/logger'
+import {AppBskyActorDefs, AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api'
import {msg as msgLingui, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {FeedDescriptor} from '#/state/queries/post-feed'
-import {EmptyState} from '../util/EmptyState'
+import {useNavigation} from '@react-navigation/native'
+
import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {FeedDescriptor} from '#/state/queries/post-feed'
import {useRemoveFeedMutation} from '#/state/queries/preferences'
+import {usePalette} from 'lib/hooks/usePalette'
+import {NavigationProp} from 'lib/routes/types'
import * as Prompt from '#/components/Prompt'
+import {EmptyState} from '../util/EmptyState'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {Button} from '../util/forms/Button'
+import {Text} from '../util/text/Text'
+import * as Toast from '../util/Toast'
export enum KnownError {
Block = 'Block',
@@ -33,10 +34,12 @@ export function FeedErrorMessage({
feedDesc,
error,
onPressTryAgain,
+ savedFeedConfig,
}: {
feedDesc: FeedDescriptor
error?: Error
onPressTryAgain: () => void
+ savedFeedConfig?: AppBskyActorDefs.SavedFeed
}) {
const {_: _l} = useLingui()
const knownError = React.useMemo(
@@ -46,13 +49,15 @@ export function FeedErrorMessage({
if (
typeof knownError !== 'undefined' &&
knownError !== KnownError.Unknown &&
- (feedDesc.startsWith('feedgen') || knownError === KnownError.FeedNSFPublic)
+ (savedFeedConfig?.type === 'feed' ||
+ knownError === KnownError.FeedNSFPublic)
) {
return (
)
}
@@ -79,10 +84,12 @@ function FeedgenErrorMessage({
feedDesc,
knownError,
rawError,
+ savedFeedConfig,
}: {
feedDesc: FeedDescriptor
knownError: KnownError
rawError?: Error
+ savedFeedConfig?: AppBskyActorDefs.SavedFeed
}) {
const pal = usePalette('default')
const {_: _l} = useLingui()
@@ -131,7 +138,8 @@ function FeedgenErrorMessage({
const onRemoveFeed = React.useCallback(async () => {
try {
- await removeFeed({uri})
+ if (!savedFeedConfig) return
+ await removeFeed(savedFeedConfig)
} catch (err) {
Toast.show(
_l(
@@ -140,7 +148,7 @@ function FeedgenErrorMessage({
)
logger.error('Failed to remove feed', {message: err})
}
- }, [uri, removeFeed, _l])
+ }, [removeFeed, _l, savedFeedConfig])
const cta = React.useMemo(() => {
switch (knownError) {
@@ -154,13 +162,14 @@ function FeedgenErrorMessage({
case KnownError.FeedgenUnknown: {
return (
- {knownError === KnownError.FeedgenDoesNotExist && (
-
- )}
+ {knownError === KnownError.FeedgenDoesNotExist &&
+ savedFeedConfig && (
+
+ )}
)}
- {preferences?.feeds?.saved?.length !== 0 && }
+
>
)
} else if (item.type === 'savedFeedNoResults') {
return (
-
- You don't have any saved feeds!
-
+ style={[
+ pal.border,
+ {
+ borderBottomWidth: 1,
+ },
+ ]}>
+
)
} else if (item.type === 'savedFeed') {
- return
+ return
} else if (item.type === 'popularFeedsHeader') {
return (
<>
@@ -521,6 +556,18 @@ export function FeedsScreen(_props: Props) {
)
+ } else if (item.type === 'noFollowingFeed') {
+ return (
+
+
+
+ )
}
return null
},
@@ -532,7 +579,6 @@ export function FeedsScreen(_props: Props) {
pal.icon,
pal.textLight,
_,
- preferences?.feeds?.saved?.length,
query,
onChangeQuery,
onPressCancelSearch,
@@ -585,16 +631,75 @@ export function FeedsScreen(_props: Props) {
)
}
-function SavedFeed({feedUri}: {feedUri: string}) {
+function FeedOrFollowing({
+ savedFeedConfig: feed,
+}: {
+ savedFeedConfig: AppBskyActorDefs.SavedFeed
+}) {
+ return feed.type === 'timeline' ? (
+
+ ) : (
+
+ )
+}
+
+function FollowingFeed() {
+ const pal = usePalette('default')
+ const t = useTheme()
+ const {isMobile} = useWebMediaQueries()
+ return (
+
+
+
+
+
+
+ Following
+
+
+
+ )
+}
+
+function SavedFeed({
+ savedFeedConfig: feed,
+}: {
+ savedFeedConfig: AppBskyActorDefs.SavedFeed
+}) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
- const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri})
- const typeAvatar = getAvatarTypeFromUri(feedUri)
+ const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value})
+ const typeAvatar = getAvatarTypeFromUri(feed.value)
if (!info)
return (
)
@@ -632,6 +737,7 @@ function SavedFeed({feedUri}: {feedUri: string}) {
) : null}
+
{isMobile && (
@@ -55,26 +56,16 @@ function HomeScreenReady({
pinnedFeedInfos,
}: Props & {
preferences: UsePreferencesQueryResponse
- pinnedFeedInfos: FeedSourceInfo[]
+ pinnedFeedInfos: SavedFeedSourceInfo[]
}) {
useOTAUpdates()
-
- const allFeeds = React.useMemo(() => {
- const feeds: FeedDescriptor[] = []
- feeds.push('home')
- for (const {uri} of pinnedFeedInfos) {
- if (uri.includes('app.bsky.feed.generator')) {
- feeds.push(`feedgen|${uri}`)
- } else if (uri.includes('app.bsky.graph.list')) {
- feeds.push(`list|${uri}`)
- }
- }
- return feeds
- }, [pinnedFeedInfos])
-
- const rawSelectedFeed = useSelectedFeed()
+ const allFeeds = React.useMemo(
+ () => pinnedFeedInfos.map(f => f.feedDescriptor),
+ [pinnedFeedInfos],
+ )
+ const rawSelectedFeed = useSelectedFeed() ?? allFeeds[0]
const setSelectedFeed = useSetSelectedFeed()
- const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor)
+ const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed)
const selectedIndex = Math.max(0, maybeFoundIndex)
const selectedFeed = allFeeds[selectedIndex]
@@ -107,12 +98,14 @@ function HomeScreenReady({
useFocusEffect(
useNonReactiveCallback(() => {
- logEvent('home:feedDisplayed', {
- index: selectedIndex,
- feedType: selectedFeed.split('|')[0],
- feedUrl: selectedFeed,
- reason: 'focus',
- })
+ if (selectedFeed) {
+ logEvent('home:feedDisplayed', {
+ index: selectedIndex,
+ feedType: selectedFeed.split('|')[0],
+ feedUrl: selectedFeed,
+ reason: 'focus',
+ })
+ }
}),
)
@@ -198,12 +191,13 @@ function HomeScreenReady({
return
}, [])
- const [homeFeed, ...customFeeds] = allFeeds
const homeFeedParams = React.useMemo(() => {
return {
mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled),
mergeFeedSources: preferences.feedViewPrefs.lab_mergeFeedEnabled
- ? preferences.feeds.saved
+ ? preferences.savedFeeds
+ .filter(f => f.type === 'feed' || f.type === 'list')
+ .map(f => f.value)
: [],
}
}, [preferences])
@@ -218,26 +212,37 @@ function HomeScreenReady({
onPageSelected={onPageSelected}
onPageScrollStateChanged={onPageScrollStateChanged}
renderTabBar={renderTabBar}>
-
- {customFeeds.map(feed => {
- return (
-
- )
- })}
+ {pinnedFeedInfos.length ? (
+ pinnedFeedInfos.map(feedInfo => {
+ const feed = feedInfo.feedDescriptor
+ if (feed === 'following') {
+ return (
+
+ )
+ }
+ const savedFeedConfig = feedInfo.savedFeed
+ return (
+
+ )
+ })
+ ) : (
+
+ )}
) : (
(null)
const isScreenFocused = useIsFocused()
- const {
- mutateAsync: saveFeed,
- variables: savedFeed,
- reset: resetSaveFeed,
- isPending: isSavePending,
- } = useSaveFeedMutation()
- const {
- mutateAsync: removeFeed,
- variables: removedFeed,
- reset: resetRemoveFeed,
- isPending: isRemovePending,
- } = useRemoveFeedMutation()
- const {
- mutateAsync: pinFeed,
- variables: pinnedFeed,
- reset: resetPinFeed,
- isPending: isPinPending,
- } = usePinFeedMutation()
- const {
- mutateAsync: unpinFeed,
- variables: unpinnedFeed,
- reset: resetUnpinFeed,
- isPending: isUnpinPending,
- } = useUnpinFeedMutation()
+ const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} =
+ useAddSavedFeedsMutation()
+ const {mutateAsync: removeFeed, isPending: isRemovePending} =
+ useRemoveFeedMutation()
+ const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} =
+ useUpdateSavedFeedsMutation()
- const isSaved =
- !removedFeed &&
- (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri))
- const isPinned =
- !unpinnedFeed &&
- (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri))
+ const isPending =
+ isAddSavedFeedPending || isRemovePending || isUpdateFeedPending
+ const savedFeedConfig = preferences.savedFeeds.find(
+ f => f.value === feedInfo.uri,
+ )
+ const isSaved = Boolean(savedFeedConfig)
+ const isPinned = Boolean(savedFeedConfig?.pinned)
useSetTitle(feedInfo?.displayName)
@@ -204,13 +186,17 @@ export function ProfileFeedScreenInner({
try {
playHaptic()
- if (isSaved) {
- await removeFeed({uri: feedInfo.uri})
- resetRemoveFeed()
+ if (savedFeedConfig) {
+ await removeFeed(savedFeedConfig)
Toast.show(_(msg`Removed from your feeds`))
} else {
- await saveFeed({uri: feedInfo.uri})
- resetSaveFeed()
+ await addSavedFeeds([
+ {
+ type: 'feed',
+ value: feedInfo.uri,
+ pinned: false,
+ },
+ ])
Toast.show(_(msg`Saved to your feeds`))
}
} catch (err) {
@@ -221,27 +207,27 @@ export function ProfileFeedScreenInner({
)
logger.error('Failed up update feeds', {message: err})
}
- }, [
- playHaptic,
- isSaved,
- removeFeed,
- feedInfo,
- resetRemoveFeed,
- _,
- saveFeed,
- resetSaveFeed,
- ])
+ }, [_, playHaptic, feedInfo, removeFeed, addSavedFeeds, savedFeedConfig])
const onTogglePinned = React.useCallback(async () => {
try {
playHaptic()
- if (isPinned) {
- await unpinFeed({uri: feedInfo.uri})
- resetUnpinFeed()
+ if (savedFeedConfig) {
+ await updateSavedFeeds([
+ {
+ ...savedFeedConfig,
+ pinned: !savedFeedConfig.pinned,
+ },
+ ])
} else {
- await pinFeed({uri: feedInfo.uri})
- resetPinFeed()
+ await addSavedFeeds([
+ {
+ type: 'feed',
+ value: feedInfo.uri,
+ pinned: true,
+ },
+ ])
}
} catch (e) {
Toast.show(_(msg`There was an issue contacting the server`))
@@ -249,13 +235,11 @@ export function ProfileFeedScreenInner({
}
}, [
playHaptic,
- isPinned,
- unpinFeed,
feedInfo,
- resetUnpinFeed,
- pinFeed,
- resetPinFeed,
_,
+ savedFeedConfig,
+ updateSavedFeeds,
+ addSavedFeeds,
])
const onPressShare = React.useCallback(() => {
@@ -296,7 +280,7 @@ export function ProfileFeedScreenInner({
{feedInfo && hasSession && (
f.value === list.uri,
+ )
+ const isPinned = Boolean(savedFeedConfig?.pinned)
const onTogglePinned = React.useCallback(async () => {
playHaptic()
try {
- if (isPinned) {
- await unpinFeed({uri: list.uri})
+ if (savedFeedConfig) {
+ const pinned = !savedFeedConfig.pinned
+ await updateSavedFeeds([
+ {
+ ...savedFeedConfig,
+ pinned,
+ },
+ ])
+ Toast.show(_(msg`${pinned ? 'Pinned to' : 'Unpinned from'} your feeds`))
} else {
- await pinFeed({uri: list.uri})
+ await addSavedFeeds([
+ {
+ type: 'list',
+ value: list.uri,
+ pinned: true,
+ },
+ ])
+ Toast.show(_(msg`Saved to your feeds`))
}
} catch (e) {
Toast.show(_(msg`There was an issue contacting the server`))
logger.error('Failed to toggle pinned feed', {message: e})
}
- }, [playHaptic, isPinned, unpinFeed, list.uri, pinFeed, _])
+ }, [
+ playHaptic,
+ addSavedFeeds,
+ updateSavedFeeds,
+ list.uri,
+ _,
+ savedFeedConfig,
+ ])
+
+ const onRemoveFromSavedFeeds = React.useCallback(async () => {
+ playHaptic()
+ if (!savedFeedConfig) return
+ try {
+ await removeSavedFeed(savedFeedConfig)
+ Toast.show(_(msg`Removed from your feeds`))
+ } catch (e) {
+ Toast.show(_(msg`There was an issue contacting the server`))
+ logger.error('Failed to remove pinned list', {message: e})
+ }
+ }, [playHaptic, removeSavedFeed, _, savedFeedConfig])
const onSubscribeMute = useCallback(async () => {
try {
@@ -345,13 +385,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const onPressDelete = useCallback(async () => {
await listDeleteMutation.mutateAsync({uri: list.uri})
- if (isSaved || isPinned) {
- const {saved, pinned} = preferences!.feeds
-
- setSavedFeeds({
- saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
- pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
- })
+ if (savedFeedConfig) {
+ await removeSavedFeed(savedFeedConfig)
}
Toast.show(_(msg`List deleted`))
@@ -367,10 +402,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
navigation,
track,
_,
- preferences,
- isPinned,
- isSaved,
- setSavedFeeds,
+ removeSavedFeed,
+ savedFeedConfig,
])
const onPressReport = useCallback(() => {
@@ -398,6 +431,22 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
},
},
]
+
+ if (savedFeedConfig) {
+ items.push({
+ testID: 'listHeaderDropdownRemoveFromFeedsBtn',
+ label: _(msg`Remove from my feeds`),
+ onPress: onRemoveFromSavedFeeds,
+ icon: {
+ ios: {
+ name: 'trash',
+ },
+ android: '',
+ web: ['far', 'trash-can'],
+ },
+ })
+ }
+
if (isOwner) {
items.push({label: 'separator'})
items.push({
@@ -444,7 +493,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
items.push({
testID: 'listHeaderDropdownUnpinBtn',
label: _(msg`Unpin moderation list`),
- onPress: isPending ? undefined : () => unpinFeed({uri: list.uri}),
+ onPress:
+ isPending || !savedFeedConfig
+ ? undefined
+ : () => removeSavedFeed(savedFeedConfig),
icon: {
ios: {
name: 'pin',
@@ -499,12 +551,13 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
deleteListPromptControl.open,
onPressReport,
isPending,
- unpinFeed,
- list.uri,
isBlocking,
isMuting,
onUnsubscribeMute,
onUnsubscribeBlock,
+ removeSavedFeed,
+ savedFeedConfig,
+ onRemoveFromSavedFeeds,
])
const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 0003dbd5..d50f9f74 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -1,5 +1,6 @@
import React from 'react'
import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -9,11 +10,11 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {track} from '#/lib/analytics/analytics'
import {logger} from '#/logger'
import {
- usePinFeedMutation,
+ useOverwriteSavedFeedsMutation,
usePreferencesQuery,
- useSetSaveFeedsMutation,
- useUnpinFeedMutation,
+ useUpdateSavedFeedsMutation,
} from '#/state/queries/preferences'
+import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSetMinimalShellMode} from '#/state/shell'
import {useAnalytics} from 'lib/analytics/analytics'
import {useHaptics} from 'lib/haptics'
@@ -27,6 +28,10 @@ import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {CenteredView, ScrollView} from 'view/com/util/Views'
+import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
+import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
+import {atoms as a, useTheme} from '#/alf'
+import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
const HITSLOP_TOP = {
top: 20,
@@ -50,23 +55,25 @@ export function SavedFeeds({}: Props) {
const setMinimalShellMode = useSetMinimalShellMode()
const {data: preferences} = usePreferencesQuery()
const {
- mutateAsync: setSavedFeeds,
+ mutateAsync: overwriteSavedFeeds,
variables: optimisticSavedFeedsResponse,
reset: resetSaveFeedsMutationState,
- error: setSavedFeedsError,
- } = useSetSaveFeedsMutation()
+ error: savedFeedsError,
+ } = useOverwriteSavedFeedsMutation()
/*
* Use optimistic data if exists and no error, otherwise fallback to remote
* data
*/
const currentFeeds =
- optimisticSavedFeedsResponse && !setSavedFeedsError
+ optimisticSavedFeedsResponse && !savedFeedsError
? optimisticSavedFeedsResponse
- : preferences?.feeds || {saved: [], pinned: []}
- const unpinned = currentFeeds.saved.filter(f => {
- return !currentFeeds.pinned?.includes(f)
- })
+ : preferences?.savedFeeds || []
+ const pinnedFeeds = currentFeeds.filter(f => f.pinned)
+ const unpinnedFeeds = currentFeeds.filter(f => !f.pinned)
+ const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0
+ const noFollowingFeed =
+ currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType
useFocusEffect(
React.useCallback(() => {
@@ -84,14 +91,20 @@ export function SavedFeeds({}: Props) {
]}>
+ {noSavedFeedsOfAnyType && (
+
+
+
+ )}
+
Pinned Feeds
- {preferences?.feeds ? (
- !currentFeeds.pinned.length ? (
+ {preferences ? (
+ !pinnedFeeds.length ? (
) : (
- currentFeeds.pinned.map(uri => (
+ pinnedFeeds.map(f => (
))
)
) : (
)}
+
+ {noFollowingFeed && (
+
+
+
+ )}
+
Saved Feeds
- {preferences?.feeds ? (
- !unpinned.length ? (
+ {preferences ? (
+ !unpinnedFeeds.length ? (
) : (
- unpinned.map(uri => (
+ unpinnedFeeds.map(f => (
))
)
@@ -174,27 +196,29 @@ export function SavedFeeds({}: Props) {
}
function ListItem({
- feedUri,
+ feed,
isPinned,
currentFeeds,
- setSavedFeeds,
+ overwriteSavedFeeds,
resetSaveFeedsMutationState,
}: {
- feedUri: string // uri
+ feed: AppBskyActorDefs.SavedFeed
isPinned: boolean
- currentFeeds: {saved: string[]; pinned: string[]}
- setSavedFeeds: ReturnType['mutateAsync']
+ currentFeeds: AppBskyActorDefs.SavedFeed[]
+ overwriteSavedFeeds: ReturnType<
+ typeof useOverwriteSavedFeedsMutation
+ >['mutateAsync']
resetSaveFeedsMutationState: ReturnType<
- typeof useSetSaveFeedsMutation
+ typeof useOverwriteSavedFeedsMutation
>['reset']
+ preferences: UsePreferencesQueryResponse
}) {
const pal = usePalette('default')
const {_} = useLingui()
const playHaptic = useHaptics()
- const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
- const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
- useUnpinFeedMutation()
- const isPending = isPinPending || isUnpinPending
+ const {isPending: isUpdatePending, mutateAsync: updateSavedFeeds} =
+ useUpdateSavedFeedsMutation()
+ const feedUri = feed.value
const onTogglePinned = React.useCallback(async () => {
playHaptic()
@@ -202,81 +226,82 @@ function ListItem({
try {
resetSaveFeedsMutationState()
- if (isPinned) {
- await unpinFeed({uri: feedUri})
- } else {
- await pinFeed({uri: feedUri})
- }
+ await updateSavedFeeds([
+ {
+ ...feed,
+ pinned: !feed.pinned,
+ },
+ ])
} catch (e) {
Toast.show(_(msg`There was an issue contacting the server`))
logger.error('Failed to toggle pinned feed', {message: e})
}
- }, [
- playHaptic,
- resetSaveFeedsMutationState,
- isPinned,
- unpinFeed,
- feedUri,
- pinFeed,
- _,
- ])
+ }, [_, playHaptic, feed, updateSavedFeeds, resetSaveFeedsMutationState])
const onPressUp = React.useCallback(async () => {
if (!isPinned) return
- // create new array, do not mutate
- const pinned = [...currentFeeds.pinned]
- const index = pinned.indexOf(feedUri)
+ const nextFeeds = currentFeeds.slice()
+ const ids = currentFeeds.map(f => f.id)
+ const index = ids.indexOf(feed.id)
+ const nextIndex = index - 1
if (index === -1 || index === 0) return
- ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
+ ;[nextFeeds[index], nextFeeds[nextIndex]] = [
+ nextFeeds[nextIndex],
+ nextFeeds[index],
+ ]
try {
- await setSavedFeeds({saved: currentFeeds.saved, pinned})
+ await overwriteSavedFeeds(nextFeeds)
track('CustomFeed:Reorder', {
- uri: feedUri,
- index: pinned.indexOf(feedUri),
+ uri: feed.value,
+ index: nextIndex,
})
} catch (e) {
Toast.show(_(msg`There was an issue contacting the server`))
logger.error('Failed to set pinned feed order', {message: e})
}
- }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _])
+ }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _])
const onPressDown = React.useCallback(async () => {
if (!isPinned) return
- const pinned = [...currentFeeds.pinned]
- const index = pinned.indexOf(feedUri)
+ const nextFeeds = currentFeeds.slice()
+ const ids = currentFeeds.map(f => f.id)
+ const index = ids.indexOf(feed.id)
+ const nextIndex = index + 1
- if (index === -1 || index >= pinned.length - 1) return
- ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
+ if (index === -1 || index >= nextFeeds.length - 1) return
+ ;[nextFeeds[index], nextFeeds[nextIndex]] = [
+ nextFeeds[nextIndex],
+ nextFeeds[index],
+ ]
try {
- await setSavedFeeds({saved: currentFeeds.saved, pinned})
+ await overwriteSavedFeeds(nextFeeds)
track('CustomFeed:Reorder', {
- uri: feedUri,
- index: pinned.indexOf(feedUri),
+ uri: feed.value,
+ index: nextIndex,
})
} catch (e) {
Toast.show(_(msg`There was an issue contacting the server`))
logger.error('Failed to set pinned feed order', {message: e})
}
- }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _])
+ }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _])
return (
-
+
{isPinned ? (
({
- opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+ opacity:
+ state.hovered || state.focused || isUpdatePending ? 0.5 : 1,
})}>
({
- opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+ opacity:
+ state.hovered || state.focused || isUpdatePending ? 0.5 : 1,
})}>
) : null}
-
- ({
- opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
- })}>
-
+ ) : (
+
-
-
+ )}
+
+ ({
+ opacity:
+ state.hovered || state.focused || isUpdatePending ? 0.5 : 1,
+ })}>
+
+
+
+
+ )
+}
+
+function FollowingFeedCard() {
+ const t = useTheme()
+ return (
+
+
+
+
+
+
+ Following
+
+
+
)
}
@@ -345,7 +423,6 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
- paddingRight: 16,
},
webArrowButtonsContainer: {
paddingLeft: 16,
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index f447490b..72e34ac4 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -1,16 +1,16 @@
import React from 'react'
-import {View, StyleSheet} from 'react-native'
-import {useNavigationState, useNavigation} from '@react-navigation/native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {TextLink} from 'view/com/util/Link'
-import {getCurrentRoute} from 'lib/routes/helpers'
-import {useLingui} from '@lingui/react'
+import {StyleSheet, View} from 'react-native'
import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation, useNavigationState} from '@react-navigation/native'
+
+import {emitSoftReset} from '#/state/events'
import {usePinnedFeedsInfos} from '#/state/queries/feed'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
-import {FeedDescriptor} from '#/state/queries/post-feed'
+import {usePalette} from 'lib/hooks/usePalette'
+import {getCurrentRoute} from 'lib/routes/helpers'
import {NavigationProp} from 'lib/routes/types'
-import {emitSoftReset} from '#/state/events'
+import {TextLink} from 'view/com/util/Link'
export function DesktopFeeds() {
const pal = usePalette('default')
@@ -31,17 +31,7 @@ export function DesktopFeeds() {
return (
{pinnedFeedInfos.map(feedInfo => {
- const uri = feedInfo.uri
- let feed: FeedDescriptor
- if (!uri) {
- feed = 'home'
- } else if (uri.includes('app.bsky.feed.generator')) {
- feed = `feedgen|${uri}`
- } else if (uri.includes('app.bsky.graph.list')) {
- feed = `list|${uri}`
- } else {
- return null
- }
+ const feed = feedInfo.feedDescriptor
return (