diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/FeedCard.tsx | 198 | ||||
-rw-r--r-- | src/components/KnownFollowers.tsx | 2 | ||||
-rw-r--r-- | src/components/Prompt.tsx | 17 | ||||
-rw-r--r-- | src/components/dms/LeaveConvoPrompt.tsx | 2 | ||||
-rw-r--r-- | src/components/icons/Arrow.tsx | 4 | ||||
-rw-r--r-- | src/screens/Profile/Header/ProfileHeaderLabeler.tsx | 2 | ||||
-rw-r--r-- | src/state/cache/profile-shadow.ts | 2 | ||||
-rw-r--r-- | src/state/queries/feed.ts | 130 | ||||
-rw-r--r-- | src/state/queries/known-followers.ts | 32 | ||||
-rw-r--r-- | src/state/queries/suggested-follows.ts | 13 | ||||
-rw-r--r-- | src/view/com/home/HomeHeaderLayout.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/GifEmbed.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Feeds.tsx | 68 | ||||
-rw-r--r-- | src/view/screens/Search/Explore.tsx | 556 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 141 |
15 files changed, 1011 insertions, 161 deletions
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx new file mode 100644 index 000000000..2745ed7c9 --- /dev/null +++ b/src/components/FeedCard.tsx @@ -0,0 +1,198 @@ +import React from 'react' +import {GestureResponderEvent, View} from 'react-native' +import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' +import {msg, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import { + useAddSavedFeedsMutation, + usePreferencesQuery, + useRemoveFeedMutation, +} from '#/state/queries/preferences' +import {sanitizeHandle} from 'lib/strings/handles' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import * as Toast from 'view/com/util/Toast' +import {useTheme} from '#/alf' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {useRichText} from '#/components/hooks/useRichText' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {Link as InternalLink} from '#/components/Link' +import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' + +export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { + return ( + <Link feed={feed}> + <Outer> + <Header> + <Avatar src={feed.avatar} /> + <TitleAndByline title={feed.displayName} creator={feed.creator} /> + <Action uri={feed.uri} pin /> + </Header> + <Description description={feed.description} /> + <Likes count={feed.likeCount || 0} /> + </Outer> + </Link> + ) +} + +export function Link({ + children, + feed, +}: { + children: React.ReactElement + feed: AppBskyFeedDefs.GeneratorView +}) { + const href = React.useMemo(() => { + const urip = new AtUri(feed.uri) + const handleOrDid = feed.creator.handle || feed.creator.did + return `/profile/${handleOrDid}/feed/${urip.rkey}` + }, [feed]) + return <InternalLink to={href}>{children}</InternalLink> +} + +export function Outer({children}: {children: React.ReactNode}) { + return <View style={[a.flex_1, a.gap_md]}>{children}</View> +} + +export function Header({children}: {children: React.ReactNode}) { + return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> +} + +export function Avatar({src}: {src: string | undefined}) { + return <UserAvatar type="algo" size={40} avatar={src} /> +} + +export function TitleAndByline({ + title, + creator, +}: { + title: string + creator: AppBskyActorDefs.ProfileViewBasic +}) { + const t = useTheme() + + return ( + <View style={[a.flex_1]}> + <Text + style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]} + numberOfLines={1}> + {title} + </Text> + <Text + style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> + </Text> + </View> + ) +} + +export function Description({description}: {description?: string}) { + const [rt, isResolving] = useRichText(description || '') + if (!description) return null + return isResolving ? ( + <RichText value={description} style={[a.leading_snug]} /> + ) : ( + <RichText value={rt} style={[a.leading_snug]} /> + ) +} + +export function Likes({count}: {count: number}) { + const t = useTheme() + return ( + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> + {plural(count || 0, { + one: 'Liked by # user', + other: 'Liked by # users', + })} + </Text> + ) +} + +export function Action({uri, pin}: {uri: string; pin?: boolean}) { + const {_} = useLingui() + const {data: preferences} = usePreferencesQuery() + const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = + useAddSavedFeedsMutation() + const {isPending: isRemovePending, mutateAsync: removeFeed} = + useRemoveFeedMutation() + const savedFeedConfig = React.useMemo(() => { + return preferences?.savedFeeds?.find( + feed => feed.type === 'feed' && feed.value === uri, + ) + }, [preferences?.savedFeeds, uri]) + const removePromptControl = Prompt.usePromptControl() + const isPending = isAddSavedFeedPending || isRemovePending + + const toggleSave = React.useCallback( + async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + if (savedFeedConfig) { + await removeFeed(savedFeedConfig) + } else { + await saveFeeds([ + { + type: 'feed', + value: uri, + pinned: pin || false, + }, + ]) + } + Toast.show(_(msg`Feeds updated!`)) + } catch (e: any) { + logger.error(e, {context: `FeedCard: failed to update feeds`, pin}) + Toast.show(_(msg`Failed to update feeds`)) + } + }, + [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig], + ) + + const onPrompRemoveFeed = React.useCallback( + async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + removePromptControl.open() + }, + [removePromptControl], + ) + + return ( + <> + <Button + disabled={isPending} + label={_(msg`Add this feed to your feeds`)} + size="small" + variant="ghost" + color="secondary" + shape="square" + onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}> + {savedFeedConfig ? ( + <ButtonIcon size="md" icon={isPending ? Loader : Trash} /> + ) : ( + <ButtonIcon size="md" icon={isPending ? Loader : Plus} /> + )} + </Button> + + <Prompt.Basic + control={removePromptControl} + title={_(msg`Remove from my feeds?`)} + description={_( + msg`Are you sure you want to remove this from your feeds?`, + )} + onConfirm={toggleSave} + confirmButtonCta={_(msg`Remove`)} + confirmButtonColor="negative" + /> + </> + ) +} diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx index a8bdb763d..63f61ce85 100644 --- a/src/components/KnownFollowers.tsx +++ b/src/components/KnownFollowers.tsx @@ -100,7 +100,7 @@ function KnownFollowersInner({ moderation, } }) - const count = cachedKnownFollowers.count - Math.min(slice.length, 2) + const count = cachedKnownFollowers.count return ( <Link diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index d05cab5ab..315ad0dfd 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -1,10 +1,10 @@ import React from 'react' -import {View} from 'react-native' +import {GestureResponderEvent, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonColor, ButtonText} from '#/components/Button' +import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Text} from '#/components/Typography' @@ -136,7 +136,7 @@ export function Action({ * Note: The dialog will close automatically when the action is pressed, you * should NOT close the dialog as a side effect of this method. */ - onPress: () => void + onPress: ButtonProps['onPress'] color?: ButtonColor /** * Optional i18n string. If undefined, it will default to "Confirm". @@ -147,9 +147,12 @@ export function Action({ const {_} = useLingui() const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() - const handleOnPress = React.useCallback(() => { - close(onPress) - }, [close, onPress]) + const handleOnPress = React.useCallback( + (e: GestureResponderEvent) => { + close(() => onPress?.(e)) + }, + [close, onPress], + ) return ( <Button @@ -186,7 +189,7 @@ export function Basic({ * Note: The dialog will close automatically when the action is pressed, you * should NOT close the dialog as a side effect of this method. */ - onConfirm: () => void + onConfirm: ButtonProps['onPress'] confirmButtonColor?: ButtonColor showCancel?: boolean }>) { diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx index 1c42dbca0..7abc76f34 100644 --- a/src/components/dms/LeaveConvoPrompt.tsx +++ b/src/components/dms/LeaveConvoPrompt.tsx @@ -49,7 +49,7 @@ export function LeaveConvoPrompt({ )} confirmButtonCta={_(msg`Leave`)} confirmButtonColor="negative" - onConfirm={leaveConvo} + onConfirm={() => leaveConvo()} /> ) } diff --git a/src/components/icons/Arrow.tsx b/src/components/icons/Arrow.tsx index eb753e549..d6fb635e9 100644 --- a/src/components/icons/Arrow.tsx +++ b/src/components/icons/Arrow.tsx @@ -7,3 +7,7 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z', }) + +export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z', +}) diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 64bf71027..6588eb2e1 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -333,7 +333,7 @@ function CantSubscribePrompt({ </Trans> </Prompt.DescriptionText> <Prompt.Actions> - <Prompt.Action onPress={control.close} cta={_(msg`OK`)} /> + <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} /> </Prompt.Actions> </Prompt.Outer> ) diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 0a618ab3b..dc907664e 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -5,6 +5,7 @@ import EventEmitter from 'eventemitter3' import {batchedUpdates} from '#/lib/batchedUpdates' import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search' +import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '../queries/known-followers' import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members' import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts' @@ -111,4 +112,5 @@ function* findProfilesInCache( yield* findAllProfilesInListConvosQueryData(queryClient, did) yield* findAllProfilesInFeedsQueryData(queryClient, did) yield* findAllProfilesInPostThreadQueryData(queryClient, did) + yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) } diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index b599ac1a0..2981b41b4 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -1,3 +1,4 @@ +import {useCallback, useEffect, useMemo, useRef} from 'react' import { AppBskyActorDefs, AppBskyFeedDefs, @@ -171,28 +172,119 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) { }) } -export const useGetPopularFeedsQueryKey = ['getPopularFeeds'] +// HACK +// the protocol doesn't yet tell us which feeds are personalized +// this list is used to filter out feed recommendations from logged out users +// for the ones we know need it +// -prf +export const KNOWN_AUTHED_ONLY_FEEDS = [ + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app + 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed + 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed + 'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow + 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky + 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz + 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why +] + +type GetPopularFeedsOptions = {limit?: number} -export function useGetPopularFeedsQuery() { +export function createGetPopularFeedsQueryKey( + options?: GetPopularFeedsOptions, +) { + return ['getPopularFeeds', options] +} + +export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { + const {hasSession} = useSession() const agent = useAgent() - return useInfiniteQuery< + const limit = options?.limit || 10 + const {data: preferences} = usePreferencesQuery() + + // Make sure this doesn't invalidate unless really needed. + const selectArgs = useMemo( + () => ({ + hasSession, + savedFeeds: preferences?.savedFeeds || [], + }), + [hasSession, preferences?.savedFeeds], + ) + const lastPageCountRef = useRef(0) + + const query = useInfiniteQuery< AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema, Error, InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>, QueryKey, string | undefined >({ - queryKey: useGetPopularFeedsQueryKey, + queryKey: createGetPopularFeedsQueryKey(options), queryFn: async ({pageParam}) => { const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: 10, + limit, cursor: pageParam, }) return res.data }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, + select: useCallback( + ( + data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>, + ) => { + const {savedFeeds, hasSession: hasSessionInner} = selectArgs + data?.pages.map(page => { + page.feeds = page.feeds.filter(feed => { + if ( + !hasSessionInner && + KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri) + ) { + return false + } + const alreadySaved = Boolean( + savedFeeds?.find(f => { + return f.value === feed.uri + }), + ) + return !alreadySaved + }) + + return page + }) + + return data + }, + [selectArgs /* Don't change. Everything needs to go into selectArgs. */], + ), }) + + useEffect(() => { + const {isFetching, hasNextPage, data} = query + if (isFetching || !hasNextPage) { + return + } + + // avoid double-fires of fetchNextPage() + if ( + lastPageCountRef.current !== 0 && + lastPageCountRef.current === data?.pages?.length + ) { + return + } + + // fetch next page if we haven't gotten a full page of content + let count = 0 + for (const page of data?.pages || []) { + count += page.feeds.length + } + if (count < limit && (data?.pages.length || 0) < 6) { + query.fetchNextPage() + lastPageCountRef.current = data?.pages?.length || 0 + } + }, [query, limit]) + + return query } export function useSearchPopularFeedsMutation() { @@ -209,6 +301,34 @@ export function useSearchPopularFeedsMutation() { }) } +const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch' +export const createPopularFeedsSearchQueryKey = (query: string) => [ + popularFeedsSearchQueryKeyRoot, + query, +] + +export function usePopularFeedsSearch({ + query, + enabled, +}: { + query: string + enabled?: boolean +}) { + const agent = useAgent() + return useQuery({ + enabled, + queryKey: createPopularFeedsSearchQueryKey(query), + queryFn: async () => { + const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ + limit: 10, + query: query, + }) + + return res.data.feeds + }, + }) +} + export type SavedFeedSourceInfo = FeedSourceInfo & { savedFeed: AppBskyActorDefs.SavedFeed } diff --git a/src/state/queries/known-followers.ts b/src/state/queries/known-followers.ts index adcbf4b50..fedd9b40f 100644 --- a/src/state/queries/known-followers.ts +++ b/src/state/queries/known-followers.ts @@ -1,5 +1,10 @@ -import {AppBskyGraphGetKnownFollowers} from '@atproto/api' -import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' +import {AppBskyActorDefs, AppBskyGraphGetKnownFollowers} from '@atproto/api' +import { + InfiniteData, + QueryClient, + QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' import {useAgent} from '#/state/session' @@ -32,3 +37,26 @@ export function useProfileKnownFollowersQuery(did: string | undefined) { enabled: !!did, }) } + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyGraphGetKnownFollowers.OutputSchema> + >({ + queryKey: [RQKEY_ROOT], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const follow of page.followers) { + if (follow.did === did) { + yield follow + } + } + } + } +} diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 59b8f7ed5..40251d43d 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -23,7 +23,10 @@ import {useAgent, useSession} from '#/state/session' import {useModerationOpts} from '../preferences/moderation-opts' const suggestedFollowsQueryKeyRoot = 'suggested-follows' -const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot] +const suggestedFollowsQueryKey = (options?: SuggestedFollowsOptions) => [ + suggestedFollowsQueryKeyRoot, + options, +] const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor' const suggestedFollowsByActorQueryKey = (did: string) => [ @@ -31,7 +34,9 @@ const suggestedFollowsByActorQueryKey = (did: string) => [ did, ] -export function useSuggestedFollowsQuery() { +type SuggestedFollowsOptions = {limit?: number} + +export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { const {currentAccount} = useSession() const agent = useAgent() const moderationOpts = useModerationOpts() @@ -46,12 +51,12 @@ export function useSuggestedFollowsQuery() { >({ enabled: !!moderationOpts && !!preferences, staleTime: STALE.HOURS.ONE, - queryKey: suggestedFollowsQueryKey, + queryKey: suggestedFollowsQueryKey(options), queryFn: async ({pageParam}) => { const contentLangs = getContentLanguages().join(',') const res = await agent.app.bsky.actor.getSuggestions( { - limit: 25, + limit: options?.limit || 25, cursor: pageParam, }, { diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index 77bdba51f..28f29ec78 100644 --- a/src/view/com/home/HomeHeaderLayout.web.tsx +++ b/src/view/com/home/HomeHeaderLayout.web.tsx @@ -57,6 +57,7 @@ function HomeHeaderLayoutDesktopAndTablet({ t.atoms.bg, t.atoms.border_contrast_low, styles.bar, + kawaii && {paddingTop: 22, paddingBottom: 16}, ]}> <View style={[ @@ -66,7 +67,7 @@ function HomeHeaderLayoutDesktopAndTablet({ a.m_auto, kawaii && {paddingTop: 4, paddingBottom: 0}, { - width: kawaii ? 60 : 28, + width: kawaii ? 84 : 28, }, ]}> <Logo width={kawaii ? 60 : 28} /> diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx index 1c0cf3d39..f2e2a8b0e 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/view/com/util/post-embeds/GifEmbed.tsx @@ -181,7 +181,7 @@ function AltText({text}: {text: string}) { <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> <Prompt.Actions> <Prompt.Action - onPress={control.close} + onPress={() => control.close()} cta={_(msg`Close`)} color="secondary" /> diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 76ff4268f..134521177 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,6 +1,6 @@ import React from 'react' import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' @@ -25,7 +25,6 @@ import {ComposeIcon2} from 'lib/icons' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {cleanError} from 'lib/strings/errors' import {s} from 'lib/styles' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {FAB} from 'view/com/util/fab/FAB' import {SearchInput} from 'view/com/util/forms/SearchInput' @@ -46,6 +45,8 @@ import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/compon import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' import hairlineWidth = StyleSheet.hairlineWidth +import {Divider} from '#/components/Divider' +import * as FeedCard from '#/components/FeedCard' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> @@ -94,6 +95,7 @@ type FlatlistSlice = type: 'popularFeed' key: string feedUri: string + feed: AppBskyFeedDefs.GeneratorView } | { type: 'popularFeedsLoadingMore' @@ -104,22 +106,6 @@ type FlatlistSlice = key: string } -// HACK -// the protocol doesn't yet tell us which feeds are personalized -// this list is used to filter out feed recommendations from logged out users -// for the ones we know need it -// -prf -const KNOWN_AUTHED_ONLY_FEEDS = [ - 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app - 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed - 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed - 'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow - 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz - 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky - 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz - 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why -] - export function FeedsScreen(_props: Props) { const pal = usePalette('default') const {openComposer} = useComposerControls() @@ -316,6 +302,7 @@ export function FeedsScreen(_props: Props) { key: `popularFeed:${feed.uri}`, type: 'popularFeed', feedUri: feed.uri, + feed, })), ) } @@ -327,10 +314,7 @@ export function FeedsScreen(_props: Props) { type: 'popularFeedsLoading', }) } else { - if ( - !popularFeeds?.pages || - popularFeeds?.pages[0]?.feeds?.length === 0 - ) { + if (!popularFeeds?.pages) { slices.push({ key: 'popularFeedsNoResults', type: 'popularFeedsNoResults', @@ -338,26 +322,12 @@ export function FeedsScreen(_props: Props) { } else { for (const page of popularFeeds.pages || []) { slices = slices.concat( - page.feeds - .filter(feed => { - if ( - !hasSession && - KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri) - ) { - return false - } - const alreadySaved = Boolean( - preferences?.savedFeeds?.find(f => { - return f.value === feed.uri - }), - ) - return !alreadySaved - }) - .map(feed => ({ - key: `popularFeed:${feed.uri}`, - type: 'popularFeed', - feedUri: feed.uri, - })), + page.feeds.map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + feed, + })), ) } @@ -495,7 +465,7 @@ export function FeedsScreen(_props: Props) { return ( <> <FeedsAboutHeader /> - <View style={{paddingHorizontal: 12, paddingBottom: 12}}> + <View style={{paddingHorizontal: 12, paddingBottom: 4}}> <SearchInput query={query} onChangeQuery={onChangeQuery} @@ -510,13 +480,10 @@ export function FeedsScreen(_props: Props) { return <FeedFeedLoadingPlaceholder /> } else if (item.type === 'popularFeed') { return ( - <FeedSourceCard - feedUri={item.feedUri} - showSaveBtn={hasSession} - showDescription - showLikes - pinOnSave - /> + <View style={[a.px_lg, a.pt_lg, a.gap_lg]}> + <FeedCard.Default feed={item.feed} /> + <Divider /> + </View> ) } else if (item.type === 'popularFeedsNoResults') { return ( @@ -559,7 +526,6 @@ export function FeedsScreen(_props: Props) { onPressCancelSearch, onSubmitQuery, onChangeSearchFocus, - hasSession, ], ) diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx new file mode 100644 index 000000000..f6e998838 --- /dev/null +++ b/src/view/screens/Search/Explore.tsx @@ -0,0 +1,556 @@ +import React from 'react' +import {View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + moderateProfile, + ModerationDecision, + ModerationOpts, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {useSession} from '#/state/session' +import {cleanError} from 'lib/strings/errors' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {List} from '#/view/com/util/List' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import { + FeedFeedLoadingPlaceholder, + ProfileCardFeedLoadingPlaceholder, +} from 'view/com/util/LoadingPlaceholder' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {Button} from '#/components/Button' +import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Props as SVGIconProps} from '#/components/icons/common' +import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' +import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +function SuggestedItemsHeader({ + title, + description, + style, + icon: Icon, +}: { + title: string + description: string + icon: React.ComponentType<SVGIconProps> +} & ViewStyleProp) { + const t = useTheme() + + return ( + <View + style={[ + isWeb + ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] + : [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md], + a.border_b, + t.atoms.border_contrast_low, + style, + ]}> + <View style={[a.flex_1, a.gap_sm]}> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Icon + size="lg" + fill={t.palette.primary_500} + style={{marginLeft: -2}} + /> + <Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>{title}</Text> + </View> + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> + {description} + </Text> + </View> + </View> + ) +} + +type LoadMoreItems = + | { + type: 'profile' + key: string + avatar: string + moderation: ModerationDecision + } + | { + type: 'feed' + key: string + avatar: string + moderation: undefined + } + +function LoadMore({ + item, + moderationOpts, +}: { + item: ExploreScreenItems & {type: 'loadMore'} + moderationOpts?: ModerationOpts +}) { + const t = useTheme() + const {_} = useLingui() + const items = React.useMemo(() => { + return item.items + .map(_item => { + if (_item.type === 'profile') { + return { + type: 'profile', + key: _item.profile.did, + avatar: _item.profile.avatar, + moderation: moderateProfile(_item.profile, moderationOpts!), + } + } else if (_item.type === 'feed') { + return { + type: 'feed', + key: _item.feed.uri, + avatar: _item.feed.avatar, + moderation: undefined, + } + } + return undefined + }) + .filter(Boolean) as LoadMoreItems[] + }, [item.items, moderationOpts]) + const type = items[0].type + + return ( + <View style={[]}> + <Button + label={_(msg`Load more`)} + onPress={item.onLoadMore} + style={[a.relative, a.w_full]}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + a.px_lg, + a.py_md, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <View + style={[ + a.relative, + { + height: 32, + width: 32 + 15 * 3, + }, + ]}> + <View + style={[ + a.align_center, + a.justify_center, + a.border, + t.atoms.bg_contrast_25, + a.absolute, + { + width: 30, + height: 30, + left: 0, + backgroundColor: t.palette.primary_500, + borderColor: t.atoms.bg.backgroundColor, + borderRadius: type === 'profile' ? 999 : 4, + zIndex: 4, + }, + ]}> + <ArrowBottom fill={t.palette.white} /> + </View> + {items.map((_item, i) => { + return ( + <View + key={_item.key} + style={[ + a.border, + t.atoms.bg_contrast_25, + a.absolute, + { + width: 30, + height: 30, + left: (i + 1) * 15, + borderColor: t.atoms.bg.backgroundColor, + borderRadius: _item.type === 'profile' ? 999 : 4, + zIndex: 3 - i, + }, + ]}> + {moderationOpts && ( + <> + {_item.type === 'profile' ? ( + <UserAvatar + size={28} + avatar={_item.avatar} + moderation={_item.moderation.ui('avatar')} + /> + ) : _item.type === 'feed' ? ( + <UserAvatar + size={28} + avatar={_item.avatar} + type="algo" + /> + ) : null} + </> + )} + </View> + ) + })} + </View> + + <Text + style={[ + a.pl_sm, + a.leading_snug, + hovered ? t.atoms.text : t.atoms.text_contrast_medium, + ]}> + {type === 'profile' ? ( + <Trans>Load more suggested follows</Trans> + ) : ( + <Trans>Load more suggested feeds</Trans> + )} + </Text> + + <View style={[a.flex_1, a.align_end]}> + {item.isLoadingMore && <Loader size="lg" />} + </View> + </View> + )} + </Button> + </View> + ) +} + +type ExploreScreenItems = + | { + type: 'header' + key: string + title: string + description: string + style?: ViewStyleProp['style'] + icon: React.ComponentType<SVGIconProps> + } + | { + type: 'profile' + key: string + profile: AppBskyActorDefs.ProfileViewBasic + } + | { + type: 'feed' + key: string + feed: AppBskyFeedDefs.GeneratorView + } + | { + type: 'loadMore' + key: string + isLoadingMore: boolean + onLoadMore: () => void + items: ExploreScreenItems[] + } + | { + type: 'profilePlaceholder' + key: string + } + | { + type: 'feedPlaceholder' + key: string + } + | { + type: 'error' + key: string + message: string + error: string + } + +export function Explore() { + const {_} = useLingui() + const t = useTheme() + const {hasSession} = useSession() + const {data: preferences, error: preferencesError} = usePreferencesQuery() + const moderationOpts = useModerationOpts() + const { + data: profiles, + hasNextPage: hasNextProfilesPage, + isLoading: isLoadingProfiles, + isFetchingNextPage: isFetchingNextProfilesPage, + error: profilesError, + fetchNextPage: fetchNextProfilesPage, + } = useSuggestedFollowsQuery({limit: 3}) + const { + data: feeds, + hasNextPage: hasNextFeedsPage, + isLoading: isLoadingFeeds, + isFetchingNextPage: isFetchingNextFeedsPage, + error: feedsError, + fetchNextPage: fetchNextFeedsPage, + } = useGetPopularFeedsQuery({limit: 3}) + + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles + const onLoadMoreProfiles = React.useCallback(async () => { + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) + return + try { + await fetchNextProfilesPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextProfilesPage, + hasNextProfilesPage, + profilesError, + fetchNextProfilesPage, + ]) + + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds + const onLoadMoreFeeds = React.useCallback(async () => { + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return + try { + await fetchNextFeedsPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextFeedsPage, + hasNextFeedsPage, + feedsError, + fetchNextFeedsPage, + ]) + + const items = React.useMemo<ExploreScreenItems[]>(() => { + const i: ExploreScreenItems[] = [ + { + type: 'header', + key: 'suggested-follows-header', + title: _(msg`Suggested accounts`), + description: _( + msg`Follow more accounts to get connected to your interests and build your network.`, + ), + icon: Person, + }, + ] + + if (profiles) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + for (const page of profiles.pages) { + for (const actor of page.actors) { + if (!seen.has(actor.did)) { + seen.add(actor.did) + i.push({ + type: 'profile', + key: actor.did, + profile: actor, + }) + } + } + } + + i.push({ + type: 'loadMore', + key: 'loadMoreProfiles', + isLoadingMore: isLoadingMoreProfiles, + onLoadMore: onLoadMoreProfiles, + items: i.filter(item => item.type === 'profile').slice(-3), + }) + } else { + if (profilesError) { + i.push({ + type: 'error', + key: 'profilesError', + message: _(msg`Failed to load suggested follows`), + error: cleanError(profilesError), + }) + } else { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } + } + + i.push({ + type: 'header', + key: 'suggested-feeds-header', + title: _(msg`Discover new feeds`), + description: _( + msg`Custom feeds built by the community bring you new experiences and help you find the content you love.`, + ), + style: [a.pt_5xl], + icon: ListSparkle, + }) + + if (feeds && preferences) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + for (const page of feeds.pages) { + for (const feed of page.feeds) { + if (!seen.has(feed.uri)) { + seen.add(feed.uri) + i.push({ + type: 'feed', + key: feed.uri, + feed, + }) + } + } + } + + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + i.push({ + type: 'loadMore', + key: 'loadMoreFeeds', + isLoadingMore: isLoadingMoreFeeds, + onLoadMore: onLoadMoreFeeds, + items: i.filter(item => item.type === 'feed').slice(-3), + }) + } + } else { + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) + } + } + + return i + }, [ + _, + profiles, + feeds, + preferences, + onLoadMoreFeeds, + onLoadMoreProfiles, + isLoadingMoreProfiles, + isLoadingMoreFeeds, + profilesError, + feedsError, + preferencesError, + ]) + + const renderItem = React.useCallback( + ({item}: {item: ExploreScreenItems}) => { + switch (item.type) { + case 'header': { + return ( + <SuggestedItemsHeader + title={item.title} + description={item.description} + style={item.style} + icon={item.icon} + /> + ) + } + case 'profile': { + return ( + <View style={[a.border_b, t.atoms.border_contrast_low]}> + <ProfileCardWithFollowBtn profile={item.profile} noBg noBorder /> + </View> + ) + } + case 'feed': { + return ( + <View style={[a.border_b, t.atoms.border_contrast_low]}> + <FeedSourceCard + feedUri={item.feed.uri} + showSaveBtn={hasSession} + showDescription + showLikes + pinOnSave + hideTopBorder + /> + </View> + ) + } + case 'loadMore': { + return <LoadMore item={item} moderationOpts={moderationOpts} /> + } + case 'profilePlaceholder': { + return <ProfileCardFeedLoadingPlaceholder /> + } + case 'feedPlaceholder': { + return <FeedFeedLoadingPlaceholder /> + } + case 'error': { + return ( + <View + style={[ + a.border_t, + a.pt_md, + a.px_md, + t.atoms.border_contrast_low, + ]}> + <View + style={[ + a.flex_row, + a.gap_md, + a.p_lg, + a.rounded_sm, + t.atoms.bg_contrast_25, + ]}> + <CircleInfo size="md" fill={t.palette.negative_400} /> + <View style={[a.flex_1, a.gap_sm]}> + <Text style={[a.font_bold, a.leading_snug]}> + {item.message} + </Text> + <Text + style={[ + a.italic, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {item.error} + </Text> + </View> + </View> + </View> + ) + } + } + }, + [t, hasSession, moderationOpts], + ) + + return ( + <List + data={items} + renderItem={renderItem} + keyExtractor={item => item.key} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 200}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + /> + ) +} diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index b6daf84b3..ed132d24e 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -29,15 +29,14 @@ import {MagnifyingGlassIcon} from '#/lib/icons' import {makeProfileLink} from '#/lib/routes/links' import {NavigationProp} from '#/lib/routes/types' import {augmentSearchQuery} from '#/lib/strings/helpers' -import {s} from '#/lib/styles' import {logger} from '#/logger' import {isIOS, isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorSearch} from '#/state/queries/actor-search' +import {usePopularFeedsSearch} from '#/state/queries/feed' import {useSearchPostsQuery} from '#/state/queries/search-posts' -import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' @@ -56,8 +55,9 @@ import {Link} from '#/view/com/util/Link' import {List} from '#/view/com/util/List' import {Text} from '#/view/com/util/text/Text' import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {Explore} from '#/view/screens/Search/Explore' import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' -import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' import {atoms as a} from '#/alf' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' @@ -122,70 +122,6 @@ function EmptyState({message, error}: {message: string; error?: string}) { ) } -function useSuggestedFollows(): [ - AppBskyActorDefs.ProfileViewBasic[], - () => void, -] { - const { - data: suggestions, - hasNextPage, - isFetchingNextPage, - isError, - fetchNextPage, - } = useSuggestedFollowsQuery() - - const onEndReached = React.useCallback(async () => { - if (isFetchingNextPage || !hasNextPage || isError) return - try { - await fetchNextPage() - } catch (err) { - logger.error('Failed to load more suggested follows', {message: err}) - } - }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - - const items: AppBskyActorDefs.ProfileViewBasic[] = [] - if (suggestions) { - // Currently the responses contain duplicate items. - // Needs to be fixed on backend, but let's dedupe to be safe. - let seen = new Set() - for (const page of suggestions.pages) { - for (const actor of page.actors) { - if (!seen.has(actor.did)) { - seen.add(actor.did) - items.push(actor) - } - } - } - } - return [items, onEndReached] -} - -let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => { - const pal = usePalette('default') - const [suggestions, onEndReached] = useSuggestedFollows() - - return suggestions.length ? ( - <List - data={suggestions} - renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />} - keyExtractor={item => item.did} - // @ts-ignore web only -prf - desktopFixedHeight - contentContainerStyle={{paddingBottom: 200}} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - onEndReached={onEndReached} - onEndReachedThreshold={2} - /> - ) : ( - <CenteredView sideBorders style={[pal.border, s.hContentRegion]}> - <ProfileCardFeedLoadingPlaceholder /> - <ProfileCardFeedLoadingPlaceholder /> - </CenteredView> - ) -} -SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows) - type SearchResultSlice = | { type: 'post' @@ -342,6 +278,50 @@ let SearchScreenUserResults = ({ } SearchScreenUserResults = React.memo(SearchScreenUserResults) +let SearchScreenFeedsResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const {_} = useLingui() + const {hasSession} = useSession() + + const {data: results, isFetched} = usePopularFeedsSearch({ + query, + enabled: active, + }) + + return isFetched && results ? ( + <> + {results.length ? ( + <List + data={results} + renderItem={({item}) => ( + <FeedSourceCard + feedUri={item.uri} + showSaveBtn={hasSession} + showDescription + showLikes + pinOnSave + /> + )} + keyExtractor={item => item.uri} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + ) +} +SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) + let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() @@ -389,6 +369,12 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { <SearchScreenUserResults query={query} active={activeTab === 2} /> ), }, + { + title: _(msg`Feeds`), + component: ( + <SearchScreenFeedsResults query={query} active={activeTab === 3} /> + ), + }, ] }, [_, query, activeTab]) @@ -408,26 +394,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { ))} </Pager> ) : hasSession ? ( - <View> - <CenteredView sideBorders style={pal.border}> - <Text - type="title" - style={[ - pal.text, - pal.border, - { - display: 'flex', - paddingVertical: 12, - paddingHorizontal: 18, - fontWeight: 'bold', - }, - ]}> - <Trans>Suggested Follows</Trans> - </Text> - </CenteredView> - - <SearchScreenSuggestedFollows /> - </View> + <Explore /> ) : ( <CenteredView sideBorders style={pal.border}> <View |