diff options
Diffstat (limited to 'src')
34 files changed, 1126 insertions, 545 deletions
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx index 2bb7ed59c..542f2d299 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 {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' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {sanitizeHandle} from '#/lib/strings/handles' type LabelingServiceProps = { labeler: AppBskyLabelerDefs.LabelerViewDetailed diff --git a/src/components/hooks/useHeaderOffset.ts b/src/components/hooks/useHeaderOffset.ts new file mode 100644 index 000000000..e2290c04f --- /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 000000000..e150b7b81 --- /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 46825d761..ea5c74f9e 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 d7bec1e18..83f513911 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 000000000..03ced8ebd --- /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 ( + <View style={[a.flex_row, a.flex_wrap, a.align_center, a.py_md, a.px_lg]}> + <Text + style={[a.leading_snug, t.atoms.text_contrast_medium, {maxWidth: 310}]}> + <Trans>Looks like you're missing a following feed.</Trans>{' '} + </Text> + + <InlineLinkText + to="/" + label={_(msg`Add the default feed of only people you follow`)} + onPress={addRecommendedFeeds} + style={[a.leading_snug]}> + <Trans>Click here to add one.</Trans> + </InlineLinkText> + </View> + ) +} diff --git a/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx b/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx new file mode 100644 index 000000000..8f6bd9d2e --- /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 ( + <View + style={[a.flex_row, a.flex_wrap, a.justify_between, a.p_xl, a.gap_md]}> + <Text + style={[a.leading_snug, t.atoms.text_contrast_medium, {maxWidth: 310}]}> + <Trans> + Looks like you haven't saved any feeds! Use our recommendations or + browse more below. + </Trans> + </Text> + + <Button + disabled={isPending} + label={_(msg`Apply default recommended feeds`)} + size="small" + variant="solid" + color="primary" + onPress={addRecommendedFeeds}> + <ButtonIcon icon={Plus} position="left" /> + <ButtonText>{_(msg`Use recommended`)}</ButtonText> + </Button> + </View> + ) +} diff --git a/src/screens/Home/NoFeedsPinned.tsx b/src/screens/Home/NoFeedsPinned.tsx new file mode 100644 index 000000000..e804e3e09 --- /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<NavigationProp>() + 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 ( + <CenteredView sideBorders style={[a.h_full_vh]}> + <View + style={[ + a.align_center, + a.h_full_vh, + a.py_3xl, + a.px_xl, + { + paddingTop: headerOffset + a.py_3xl.paddingTop, + }, + ]}> + <View style={[a.align_center, a.gap_sm, a.pb_xl]}> + <Text style={[a.text_xl, a.font_bold]}> + <Trans>Whoops!</Trans> + </Text> + <Text + style={[a.text_md, a.text_center, a.leading_snug, {maxWidth: 340}]}> + <Trans> + Looks like you unpinned all your feeds. But don't worry, you can + add some below 😄 + </Trans> + </Text> + </View> + + <View style={[a.flex_row, a.gap_md, a.justify_center, a.flex_wrap]}> + <Button + disabled={isPending} + label={_(msg`Apply default recommended feeds`)} + size="medium" + variant="solid" + color="primary" + onPress={addRecommendedFeeds}> + <ButtonIcon icon={Plus} position="left" /> + <ButtonText>{_(msg`Add recommended feeds`)}</ButtonText> + </Button> + + <Link + label={_(msg`Browse other feeds`)} + to="/feeds" + onPress={onPressFeedsLink} + size="medium" + variant="solid" + color="secondary"> + <ButtonIcon icon={ListSparkle} position="left" /> + <ButtonText>{_(msg`Browse other feeds`)}</ButtonText> + </Link> + </View> + </View> + </CenteredView> + ) +} diff --git a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx index d2b2a5f39..0aa063faa 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 e7054fb1f..4cc611ef4 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 c4954d20a..ac129d172 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 1741d113c..19cded087 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<string, FeedSourceInfo>() // 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 dc86a9ba0..7b312edfe 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 4cb4d1e96..d94edb47e 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -1,8 +1,8 @@ +import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' import { - UsePreferencesQueryResponse, ThreadViewPreferences, + UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' -import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' 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 f51eaac2a..b3d2fa9ec 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<void, unknown, Partial<BskyFeedViewPreference>>({ 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<UsePreferencesQueryResponse['feeds'], 'saved' | 'pinned'> - >({ - mutationFn: async ({saved, pinned}) => { - await getAgent().setSavedFeeds(saved, pinned) + return useMutation<void, unknown, AppBskyActorDefs.SavedFeed[]>({ + 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<void, unknown, {uri: string}>({ - mutationFn: async ({uri}) => { - await getAgent().addSavedFeed(uri) + return useMutation< + void, + unknown, + Pick<AppBskyActorDefs.SavedFeed, 'type' | 'value' | 'pinned'>[] + >({ + 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<void, unknown, {uri: string}>({ - mutationFn: async ({uri}) => { - await getAgent().removeSavedFeed(uri) + return useMutation<void, unknown, Pick<AppBskyActorDefs.SavedFeed, 'id'>>({ + 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<void, unknown, {uri: string}>({ - mutationFn: async ({uri}) => { - await getAgent().addPinnedFeed(uri) - track('CustomFeed:Pin', {uri}) - // triggers a refetch - await queryClient.invalidateQueries({ - queryKey: preferencesQueryKey, - }) - }, - }) -} - -export function useUnpinFeedMutation() { - const queryClient = useQueryClient() - const {getAgent} = useAgent() + return useMutation<void, unknown, AppBskyActorDefs.SavedFeed[]>({ + mutationFn: async feeds => { + await getAgent().updateSavedFeeds(feeds) - return useMutation<void, unknown, {uri: string}>({ - 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 96da16f1a..928bb90da 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<BskyPreferences['feeds']> & { - unpinned: string[] - } } export type ThreadViewPreferences = Pick< diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 024f6e7d1..9633dc0e3 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 df50b3952..08b7ba77c 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<StateContext>('home') +const stateContext = React.createContext<StateContext>(null) const setContext = React.createContext<SetContext>((_: 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 bb782809d..6a9fc9346 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} /> </FeedFeedbackProvider> </MainScrollProvider> @@ -153,16 +157,3 @@ export function FeedPage({ </View> ) } - -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 8a21d86ae..bb536bccd 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 {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 {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' +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}> - <View style={[styles.headerContainer]}> + <View style={[styles.headerContainer, a.align_start]}> <View style={[s.mr10]}> <UserAvatar type="algo" size={36} avatar={feed.avatar} /> </View> @@ -221,11 +223,11 @@ export function FeedSourceCardLoaded({ </Text> </View> - {showSaveBtn && feed.type === 'feed' && ( + {showSaveBtn && ( <View style={[s.justifyCenter]}> <Pressable testID={`feed-${feed.displayName}-toggleSave`} - disabled={isSavePending || isPinPending || isRemovePending} + disabled={isAddSavedFeedPending || isRemovePending} accessibilityRole="button" accessibilityLabel={ isSaved diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index aa3ecb7fc..b068484e8 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -1,12 +1,14 @@ import React from 'react' -import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {HomeHeaderLayout} from './HomeHeaderLayout' -import {FeedSourceInfo} from '#/state/queries/feed' import {useNavigation} from '@react-navigation/native' + +import {usePalette} from '#/lib/hooks/usePalette' +import {FeedSourceInfo} from '#/state/queries/feed' +import {useSession} from '#/state/session' import {NavigationProp} from 'lib/routes/types' import {isWeb} from 'platform/detection' +import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {TabBar} from '../pager/TabBar' -import {usePalette} from '#/lib/hooks/usePalette' +import {HomeHeaderLayout} from './HomeHeaderLayout' export function HomeHeader( props: RenderTabBarFnProps & { @@ -16,12 +18,17 @@ export function HomeHeader( }, ) { const {feeds} = props + const {hasSession} = useSession() const navigation = useNavigation<NavigationProp>() const pal = usePalette('default') const hasPinnedCustom = React.useMemo<boolean>(() => { - 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 fd4c486af..a95a94835 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 2b83c7a9a..ce3fbcef8 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 ff8acd60c..5791e26a9 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({ <Text type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'} testID={testID ? `${testID}-${item}` : undefined} - style={selected ? pal.text : pal.textLight}> + style={[ + selected ? pal.text : pal.textLight, + {lineHeight: 20}, + ]}> {item} </Text> </View> diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 8969f7cd2..c51733d1b 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 <FeedSlice slice={item} /> }, - [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 d4ca38d07..a152bc909 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 ( <FeedgenErrorMessage feedDesc={feedDesc} knownError={knownError} rawError={error} + savedFeedConfig={savedFeedConfig} /> ) } @@ -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 ( <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> - {knownError === KnownError.FeedgenDoesNotExist && ( - <Button - type="inverted" - label={_l(msgLingui`Remove feed`)} - onPress={onRemoveFeed} - /> - )} + {knownError === KnownError.FeedgenDoesNotExist && + savedFeedConfig && ( + <Button + type="inverted" + label={_l(msgLingui`Remove feed`)} + onPress={onRemoveFeed} + /> + )} <Button type="default-light" label={_l(msgLingui`View profile`)} @@ -170,7 +179,7 @@ function FeedgenErrorMessage({ ) } } - }, [knownError, onViewProfile, onRemoveFeed, _l]) + }, [knownError, onViewProfile, onRemoveFeed, _l, savedFeedConfig]) return ( <> diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index c1af39a5d..f58417887 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -1,14 +1,15 @@ import React, {memo, useCallback} from 'react' import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native' +import {msg, plural} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useModalControls} from '#/state/modals' +import {useRequireAuth} from '#/state/session' +import {HITSLOP_10, HITSLOP_20} from 'lib/constants' import {RepostIcon} from 'lib/icons' -import {s, colors} from 'lib/styles' +import {colors, s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {Text} from '../text/Text' -import {HITSLOP_10, HITSLOP_20} from 'lib/constants' -import {useModalControls} from '#/state/modals' -import {useRequireAuth} from '#/state/session' -import {msg, plural} from '@lingui/macro' -import {useLingui} from '@lingui/react' interface Props { isReposted: boolean diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 78935edae..826f997dd 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -6,6 +6,7 @@ import { StyleSheet, View, } from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' @@ -44,8 +45,11 @@ import { import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' import {ViewHeader} from 'view/com/util/ViewHeader' +import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' +import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {atoms as a, useTheme} from '#/alf' import {IconCircle} from '#/components/IconCircle' +import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' @@ -74,6 +78,7 @@ type FlatlistSlice = type: 'savedFeed' key: string feedUri: string + savedFeedConfig: AppBskyActorDefs.SavedFeed } | { type: 'savedFeedsLoadMore' @@ -100,6 +105,10 @@ type FlatlistSlice = type: 'popularFeedsLoadingMore' key: string } + | { + type: 'noFollowingFeed' + key: string + } // HACK // the protocol doesn't yet tell us which feeds are personalized @@ -229,33 +238,54 @@ export function FeedsScreen(_props: Props) { error: cleanError(preferencesError.toString()), }) } else { - if (isPreferencesLoading || !preferences?.feeds?.saved) { + if (isPreferencesLoading || !preferences?.savedFeeds) { slices.push({ key: 'savedFeedsLoading', type: 'savedFeedsLoading', // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, }) } else { - if (preferences?.feeds?.saved.length !== 0) { - const {saved, pinned} = preferences.feeds + if (preferences.savedFeeds?.length) { + const noFollowingFeed = preferences.savedFeeds.every( + f => f.type !== 'timeline', + ) slices = slices.concat( - pinned.map(uri => ({ - key: `savedFeed:${uri}`, - type: 'savedFeed', - feedUri: uri, - })), + preferences.savedFeeds + .filter(f => { + return f.pinned + }) + .map(feed => ({ + key: `savedFeed:${feed.value}:${feed.id}`, + type: 'savedFeed', + feedUri: feed.value, + savedFeedConfig: feed, + })), ) - slices = slices.concat( - saved - .filter(uri => !pinned.includes(uri)) - .map(uri => ({ - key: `savedFeed:${uri}`, + preferences.savedFeeds + .filter(f => { + return !f.pinned + }) + .map(feed => ({ + key: `savedFeed:${feed.value}:${feed.id}`, type: 'savedFeed', - feedUri: uri, + feedUri: feed.value, + savedFeedConfig: feed, })), ) + + if (noFollowingFeed) { + slices.push({ + key: 'noFollowingFeed', + type: 'noFollowingFeed', + }) + } + } else { + slices.push({ + key: 'savedFeedNoResults', + type: 'savedFeedNoResults', + }) } } } @@ -323,7 +353,12 @@ export function FeedsScreen(_props: Props) { ) { return false } - return !preferences?.feeds?.saved.includes(feed.uri) + const alreadySaved = Boolean( + preferences?.savedFeeds?.find(f => { + return f.value === feed.uri + }), + ) + return !alreadySaved }) .map(feed => ({ key: `popularFeed:${feed.uri}`, @@ -463,23 +498,23 @@ export function FeedsScreen(_props: Props) { </View> </View> )} - {preferences?.feeds?.saved?.length !== 0 && <FeedsSavedHeader />} + <FeedsSavedHeader /> </> ) } else if (item.type === 'savedFeedNoResults') { return ( <View - style={{ - paddingHorizontal: 16, - paddingTop: 10, - }}> - <Text type="lg" style={pal.textLight}> - <Trans>You don't have any saved feeds!</Trans> - </Text> + style={[ + pal.border, + { + borderBottomWidth: 1, + }, + ]}> + <NoSavedFeedsOfAnyType /> </View> ) } else if (item.type === 'savedFeed') { - return <SavedFeed feedUri={item.feedUri} /> + return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} /> } else if (item.type === 'popularFeedsHeader') { return ( <> @@ -521,6 +556,18 @@ export function FeedsScreen(_props: Props) { </Text> </View> ) + } else if (item.type === 'noFollowingFeed') { + return ( + <View + style={[ + pal.border, + { + borderBottomWidth: 1, + }, + ]}> + <NoFollowingFeed /> + </View> + ) } 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' ? ( + <FollowingFeed /> + ) : ( + <SavedFeed savedFeedConfig={feed} /> + ) +} + +function FollowingFeed() { const pal = usePalette('default') + const t = useTheme() const {isMobile} = useWebMediaQueries() - const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri}) - const typeAvatar = getAvatarTypeFromUri(feedUri) + return ( + <View + testID={`saved-feed-timeline`} + style={[ + pal.border, + styles.savedFeed, + isMobile && styles.savedFeedMobile, + ]}> + <View + style={[ + a.align_center, + a.justify_center, + { + width: 28, + height: 28, + borderRadius: 3, + backgroundColor: t.palette.primary_500, + }, + ]}> + <FilterTimeline + style={[ + { + width: 18, + height: 18, + }, + ]} + fill={t.palette.white} + /> + </View> + <View + style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> + <Text type="lg-medium" style={pal.text} numberOfLines={1}> + <Trans>Following</Trans> + </Text> + </View> + </View> + ) +} + +function SavedFeed({ + savedFeedConfig: feed, +}: { + savedFeedConfig: AppBskyActorDefs.SavedFeed +}) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value}) + const typeAvatar = getAvatarTypeFromUri(feed.value) if (!info) return ( <SavedFeedLoadingPlaceholder - key={`savedFeedLoadingPlaceholder:${feedUri}`} + key={`savedFeedLoadingPlaceholder:${feed.value}`} /> ) @@ -632,6 +737,7 @@ function SavedFeed({feedUri}: {feedUri: string}) { </View> ) : null} </View> + {isMobile && ( <FontAwesomeIcon icon="chevron-right" diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 665400f14..bd17e5fe4 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -8,8 +8,8 @@ import {useSetTitle} from '#/lib/hooks/useSetTitle' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {logEvent, LogEvents, useGate} from '#/lib/statsig/statsig' import {emitSoftReset} from '#/state/events' -import {FeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' -import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' +import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' +import {FeedParams} from '#/state/queries/post-feed' import {usePreferencesQuery} from '#/state/queries/preferences' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useSession} from '#/state/session' @@ -26,6 +26,7 @@ import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' +import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' import {HomeHeader} from '../com/home/HomeHeader' type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> @@ -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 <CustomFeedEmptyState /> }, []) - const [homeFeed, ...customFeeds] = allFeeds const homeFeedParams = React.useMemo<FeedParams>(() => { 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}> - <FeedPage - key={homeFeed} - testID="followingFeedPage" - isPageFocused={selectedFeed === homeFeed} - feed={homeFeed} - feedParams={homeFeedParams} - renderEmptyState={renderFollowingEmptyState} - renderEndOfFeed={FollowingEndOfFeed} - /> - {customFeeds.map(feed => { - return ( - <FeedPage - key={feed} - testID="customFeedPage" - isPageFocused={selectedFeed === feed} - feed={feed} - renderEmptyState={renderCustomFeedEmptyState} - /> - ) - })} + {pinnedFeedInfos.length ? ( + pinnedFeedInfos.map(feedInfo => { + const feed = feedInfo.feedDescriptor + if (feed === 'following') { + return ( + <FeedPage + key={feed} + testID="followingFeedPage" + isPageFocused={selectedFeed === feed} + feed={feed} + feedParams={homeFeedParams} + renderEmptyState={renderFollowingEmptyState} + renderEndOfFeed={FollowingEndOfFeed} + /> + ) + } + const savedFeedConfig = feedInfo.savedFeed + return ( + <FeedPage + key={feed} + testID="customFeedPage" + isPageFocused={selectedFeed === feed} + feed={feed} + renderEmptyState={renderCustomFeedEmptyState} + savedFeedConfig={savedFeedConfig} + /> + ) + }) + ) : ( + <NoFeedsPinned preferences={preferences} /> + )} </Pager> ) : ( <Pager diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx index 724c3f265..b427a0f2b 100644 --- a/src/view/screens/PreferencesFollowingFeed.tsx +++ b/src/view/screens/PreferencesFollowingFeed.tsx @@ -1,23 +1,24 @@ import React, {useState} from 'react' import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {Slider} from '@miblanchard/react-native-slider' -import {Text} from '../com/util/text/Text' -import {s, colors} from 'lib/styles' +import debounce from 'lodash.debounce' + +import { + usePreferencesQuery, + useSetFeedViewPreferencesMutation, +} from '#/state/queries/preferences' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {colors, s} from 'lib/styles' import {isWeb} from 'platform/detection' import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' -import debounce from 'lodash.debounce' -import {Trans, msg, Plural} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import { - usePreferencesQuery, - useSetFeedViewPreferencesMutation, -} from '#/state/queries/preferences' +import {Text} from '../com/util/text/Text' function RepliesThresholdInput({ enabled, diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index f66231ab5..3dd8c3ac8 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -16,12 +16,11 @@ import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' import {FeedDescriptor} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import { - usePinFeedMutation, + useAddSavedFeedsMutation, usePreferencesQuery, UsePreferencesQueryResponse, useRemoveFeedMutation, - useSaveFeedMutation, - useUnpinFeedMutation, + useUpdateSavedFeedsMutation, } from '#/state/queries/preferences' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {truncateAndInvalidate} from '#/state/queries/util' @@ -163,37 +162,20 @@ export function ProfileFeedScreenInner({ const feedSectionRef = React.useRef<SectionRef>(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 isSaved = - !removedFeed && - (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri)) - const isPinned = - !unpinnedFeed && - (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri)) + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = + useAddSavedFeedsMutation() + const {mutateAsync: removeFeed, isPending: isRemovePending} = + useRemoveFeedMutation() + const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = + useUpdateSavedFeedsMutation() + + 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 && ( <NewButton testID={isPinned ? 'unpinBtn' : 'pinBtn'} - disabled={isPinPending || isUnpinPending} + disabled={isPending} size="small" variant="solid" color={isPinned ? 'secondary' : 'primary'} @@ -339,7 +323,7 @@ export function ProfileFeedScreenInner({ {hasSession && ( <> <Menu.Item - disabled={isSavePending || isRemovePending} + disabled={isPending} testID="feedHeaderDropdownToggleSavedBtn" label={ isSaved @@ -395,14 +379,11 @@ export function ProfileFeedScreenInner({ onTogglePinned, onToggleSaved, currentAccount?.did, - isPinPending, - isRemovePending, - isSavePending, isSaved, - isUnpinPending, onPressReport, onPressShare, t, + isPending, ]) return ( diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 2902ccf5e..6bbe63b9e 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -23,10 +23,10 @@ import { import {FeedDescriptor} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import { - usePinFeedMutation, + useAddSavedFeedsMutation, usePreferencesQuery, - useSetSaveFeedsMutation, - useUnpinFeedMutation, + useRemoveFeedMutation, + useUpdateSavedFeedsMutation, } from '#/state/queries/preferences' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {truncateAndInvalidate} from '#/state/queries/util' @@ -248,36 +248,76 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const isBlocking = !!list.viewer?.blocked const isMuting = !!list.viewer?.muted const isOwner = list.creator.did === currentAccount?.did - const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() - const {isPending: isUnpinPending, mutateAsync: unpinFeed} = - useUnpinFeedMutation() - const isPending = isPinPending || isUnpinPending const {data: preferences} = usePreferencesQuery() - const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() const {track} = useAnalytics() const playHaptic = useHaptics() + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = + useAddSavedFeedsMutation() + const {mutateAsync: removeSavedFeed, isPending: isRemovePending} = + useRemoveFeedMutation() + const {mutateAsync: updateSavedFeeds, isPending: isUpdatingSavedFeeds} = + useUpdateSavedFeedsMutation() + + const isPending = + isAddSavedFeedPending || isRemovePending || isUpdatingSavedFeeds + const deleteListPromptControl = useDialogControl() const subscribeMutePromptControl = useDialogControl() const subscribeBlockPromptControl = useDialogControl() - const isPinned = preferences?.feeds?.pinned?.includes(list.uri) - const isSaved = preferences?.feeds?.saved?.includes(list.uri) + const savedFeedConfig = preferences?.savedFeeds?.find( + f => 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 0003dbd5d..d50f9f74d 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) { ]}> <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder /> <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}> + {noSavedFeedsOfAnyType && ( + <View style={[pal.border, {borderBottomWidth: 1}]}> + <NoSavedFeedsOfAnyType /> + </View> + )} + <View style={[pal.text, pal.border, styles.title]}> <Text type="title" style={pal.text}> <Trans>Pinned Feeds</Trans> </Text> </View> - {preferences?.feeds ? ( - !currentFeeds.pinned.length ? ( + {preferences ? ( + !pinnedFeeds.length ? ( <View style={[ pal.border, @@ -104,27 +117,35 @@ export function SavedFeeds({}: Props) { </Text> </View> ) : ( - currentFeeds.pinned.map(uri => ( + pinnedFeeds.map(f => ( <ListItem - key={uri} - feedUri={uri} + key={f.id} + feed={f} isPinned - setSavedFeeds={setSavedFeeds} + overwriteSavedFeeds={overwriteSavedFeeds} resetSaveFeedsMutationState={resetSaveFeedsMutationState} currentFeeds={currentFeeds} + preferences={preferences} /> )) ) ) : ( <ActivityIndicator style={{marginTop: 20}} /> )} + + {noFollowingFeed && ( + <View style={[pal.border, {borderBottomWidth: 1}]}> + <NoFollowingFeed /> + </View> + )} + <View style={[pal.text, pal.border, styles.title]}> <Text type="title" style={pal.text}> <Trans>Saved Feeds</Trans> </Text> </View> - {preferences?.feeds ? ( - !unpinned.length ? ( + {preferences ? ( + !unpinnedFeeds.length ? ( <View style={[ pal.border, @@ -137,14 +158,15 @@ export function SavedFeeds({}: Props) { </Text> </View> ) : ( - unpinned.map(uri => ( + unpinnedFeeds.map(f => ( <ListItem - key={uri} - feedUri={uri} + key={f.id} + feed={f} isPinned={false} - setSavedFeeds={setSavedFeeds} + overwriteSavedFeeds={overwriteSavedFeeds} resetSaveFeedsMutationState={resetSaveFeedsMutationState} currentFeeds={currentFeeds} + preferences={preferences} /> )) ) @@ -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<typeof useSetSaveFeedsMutation>['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 ( - <Pressable - accessibilityRole="button" - style={[styles.itemContainer, pal.border]}> + <View style={[styles.itemContainer, pal.border]}> {isPinned ? ( <View style={styles.webArrowButtonsContainer}> <Pressable - disabled={isPending} + disabled={isUpdatePending} accessibilityRole="button" onPress={onPressUp} hitSlop={HITSLOP_TOP} style={state => ({ - opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + opacity: + state.hovered || state.focused || isUpdatePending ? 0.5 : 1, })}> <FontAwesomeIcon icon="arrow-up" @@ -285,39 +310,92 @@ function ListItem({ /> </Pressable> <Pressable - disabled={isPending} + disabled={isUpdatePending} accessibilityRole="button" onPress={onPressDown} hitSlop={HITSLOP_BOTTOM} style={state => ({ - opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + opacity: + state.hovered || state.focused || isUpdatePending ? 0.5 : 1, })}> <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} /> </Pressable> </View> ) : null} - <FeedSourceCard - key={feedUri} - feedUri={feedUri} - style={styles.noTopBorder} - showSaveBtn - showMinimalPlaceholder - /> - <Pressable - disabled={isPending} - accessibilityRole="button" - hitSlop={10} - onPress={onTogglePinned} - style={state => ({ - opacity: state.hovered || state.focused || isPending ? 0.5 : 1, - })}> - <FontAwesomeIcon - icon="thumb-tack" - size={20} - color={isPinned ? colors.blue3 : pal.colors.icon} + {feed.type === 'timeline' ? ( + <FollowingFeedCard /> + ) : ( + <FeedSourceCard + key={feedUri} + feedUri={feedUri} + style={styles.noTopBorder} + showSaveBtn + showMinimalPlaceholder + /> + )} + <View style={{paddingRight: 16}}> + <Pressable + disabled={isUpdatePending} + accessibilityRole="button" + hitSlop={10} + onPress={onTogglePinned} + style={state => ({ + opacity: + state.hovered || state.focused || isUpdatePending ? 0.5 : 1, + })}> + <FontAwesomeIcon + icon="thumb-tack" + size={20} + color={isPinned ? colors.blue3 : pal.colors.icon} + /> + </Pressable> + </View> + </View> + ) +} + +function FollowingFeedCard() { + const t = useTheme() + return ( + <View + style={[ + a.flex_row, + a.align_center, + a.flex_1, + { + paddingHorizontal: 18, + paddingVertical: 20, + }, + ]}> + <View + style={[ + a.align_center, + a.justify_center, + a.rounded_sm, + { + width: 36, + height: 36, + backgroundColor: t.palette.primary_500, + marginRight: 10, + }, + ]}> + <FilterTimeline + style={[ + { + width: 22, + height: 22, + }, + ]} + fill={t.palette.white} /> - </Pressable> - </Pressable> + </View> + <View + style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> + <Text type="lg-medium" style={[t.atoms.text]} numberOfLines={1}> + <Trans>Following</Trans> + </Text> + </View> + </View> ) } @@ -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 f447490b3..72e34ac46 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 ( <View style={[styles.container, pal.view]}> {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 ( <FeedItem key={feed} |