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 && ( -