diff --git a/package.json b/package.json index ae08413d33..51289561e4 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.28", + "@atproto/api": "0.20.31", "@atproto/common-web": "0.5.6", "@atproto/syntax": "0.7.2", "@bitdrift/react-native": "^0.6.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b64d2b20cb..1da8629683 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.28 - version: 0.20.28 + specifier: 0.20.31 + version: 0.20.31 '@atproto/common-web': specifier: 0.5.6 version: 0.5.6 @@ -865,8 +865,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.28': - resolution: {integrity: sha512-/Rvk8zt9mtRi9tlMD2Qg+NG2lMj3B0HDjmfswR5724pH7GsOEMDDHwleVlmOBSrgACIyVSa5tdCDJ+R+SEhwww==} + '@atproto/api@0.20.31': + resolution: {integrity: sha512-TovCQLQv5ti1jqh8UH6jJ0EFuWRjGdUtFyFR5xYC/IkIulwHDuyrVaXdCv7VLWiHftp92DevtZnFRm+BZsZZdw==} engines: {node: '>=22'} '@atproto/common-web@0.5.6': @@ -9208,7 +9208,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.28': + '@atproto/api@0.20.31': dependencies: '@atproto/common-web': 0.5.6 '@atproto/lexicon': 0.7.7 diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 89c6632589..aa525e6495 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -779,13 +779,13 @@ export type Events = { } 'search:results:loaded': { - tab: 'top' | 'latest' | 'people' | 'feeds' + tab: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks' initialCount: number } 'search:result:press': { - tab?: 'top' | 'latest' | 'people' | 'feeds' - resultType: 'post' | 'profile' | 'feed' + tab?: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks' + resultType: 'post' | 'profile' | 'feed' | 'starterPack' position: number uri: string } diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx index 70755e8148..ed405d3265 100644 --- a/src/screens/Search/SearchResults.tsx +++ b/src/screens/Search/SearchResults.tsx @@ -1,6 +1,6 @@ import {memo, useCallback, useMemo, useState} from 'react' import {ActivityIndicator, View} from 'react-native' -import {type AppBskyFeedDefs} from '@atproto/api' +import {type AppBskyFeedDefs, type AppBskyGraphDefs} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {urls} from '#/lib/constants' @@ -15,6 +15,7 @@ import {augmentSearchQuery} from '#/lib/strings/helpers' import {useActorSearch} from '#/state/queries/actor-search' import {usePopularFeedsSearch} from '#/state/queries/feed' import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2' +import {useStarterPackSearch} from '#/state/queries/starter-pack-search' import {useSession} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' @@ -23,6 +24,7 @@ import {TabBar} from '#/view/com/pager/TabBar' import {Post} from '#/view/com/post/Post' import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' import {List} from '#/view/com/util/List' +import {StarterPackCard} from '#/screens/Search/components/StarterPackCard' import { hasPostOnlyFilters, type SearchFilters, @@ -106,6 +108,15 @@ let SearchResults = ({ ), }, + noFilters && { + title: l`Starter packs`, + component: ( + + ), + }, ].filter(Boolean) as { title: string component: React.ReactNode @@ -692,3 +703,123 @@ function SearchFeedCard({ return } + +let SearchScreenStarterPackResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const ax = useAnalytics() + const {t: l} = useLingui() + const [isPTR, setIsPTR] = useState(false) + + const { + isFetched, + data: results, + isFetching, + error, + refetch, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + } = useStarterPackSearch({ + query, + enabled: active, + }) + + const onPullToRefresh = useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [setIsPTR, refetch]) + const onEndReached = useCallback(() => { + if (isFetching || !hasNextPage || error) return + void fetchNextPage() + }, [isFetching, error, hasNextPage, fetchNextPage]) + const starterPacks = useMemo(() => { + return results?.pages.flatMap(page => page.starterPacks) || [] + }, [results]) + + const fireTracking = useCallOnce(() => { + ax.metric('search:results:loaded', { + tab: 'starterPacks', + initialCount: starterPacks.length, + }) + }) + if (isFetched) { + fireTracking() + } + + if (error) { + return ( + + ) + } + + return isFetched ? ( + <> + {starterPacks.length ? ( + ( + + + + )} + keyExtractor={(item: AppBskyGraphDefs.StarterPackView) => item.uri} + refreshing={isPTR} + onRefresh={() => void onPullToRefresh()} + onEndReached={onEndReached} + desktopFixedHeight + ListFooterComponent={ + + } + /> + ) : ( + } /> + )} + + ) : ( + + ) +} +SearchScreenStarterPackResults = memo(SearchScreenStarterPackResults) + +function SearchStarterPack({ + position, + view, +}: { + position: number + view: AppBskyGraphDefs.StarterPackView +}) { + const ax = useAnalytics() + + const handleOnPress = () => { + ax.metric('search:result:press', { + tab: 'starterPacks', + resultType: 'starterPack', + position, + uri: view.uri, + }) + } + + return +} diff --git a/src/screens/Search/components/StarterPackCard.tsx b/src/screens/Search/components/StarterPackCard.tsx index bc0920fdb8..9831746b42 100644 --- a/src/screens/Search/components/StarterPackCard.tsx +++ b/src/screens/Search/components/StarterPackCard.tsx @@ -26,8 +26,10 @@ import * as bsky from '#/types/bsky' export function StarterPackCard({ view, + onPress, }: { view: AppBskyGraphDefs.StarterPackView + onPress?: () => void }) { const t = useTheme() const {_} = useLingui() @@ -55,7 +57,10 @@ export function StarterPackCard({ to={link.to} label={link.label} onHoverIn={link.precache} - onPress={link.precache}> + onPress={() => { + link.precache() + onPress?.() + }}> {s => ( <> @@ -111,7 +116,10 @@ export function StarterPackCard({ to={link.to} label={link.label} onHoverIn={link.precache} - onPress={link.precache} + onPress={() => { + link.precache() + onPress?.() + }} variant="solid" color="secondary" size="small" diff --git a/src/state/queries/starter-pack-search.ts b/src/state/queries/starter-pack-search.ts new file mode 100644 index 0000000000..7987d1e07f --- /dev/null +++ b/src/state/queries/starter-pack-search.ts @@ -0,0 +1,75 @@ +import {type AppBskyGraphSearchStarterPacksV2} from '@atproto/api' +import { + type InfiniteData, + keepPreviousData, + type QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' + +export const RQKEY_ROOT = 'starter-pack-search' +export const RQKEY = (query: string, limit?: number) => [ + RQKEY_ROOT, + query, + limit, +] + +export function useStarterPackSearch({ + query, + enabled, + maintainData, + limit = 25, +}: { + query: string + enabled?: boolean + maintainData?: boolean + limit?: number +}) { + const agent = useAgent() + return useInfiniteQuery< + AppBskyGraphSearchStarterPacksV2.OutputSchema, + Error, + InfiniteData, + QueryKey, + string | undefined + >({ + staleTime: STALE.MINUTES.FIVE, + queryKey: RQKEY(query, limit), + queryFn: async ({pageParam}) => { + const res = await agent.app.bsky.graph.searchStarterPacksV2({ + q: query, + limit, + cursor: pageParam, + }) + return res.data + }, + enabled: enabled && !!query, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + placeholderData: maintainData ? keepPreviousData : undefined, + select, + }) +} + +function select( + data: InfiniteData, +) { + // enforce uniqueness + const uris = new Set() + + return { + ...data, + pages: data.pages.map(page => ({ + ...page, + starterPacks: page.starterPacks.filter(starterPack => { + if (uris.has(starterPack.uri)) { + return false + } + uris.add(starterPack.uri) + return true + }), + })), + } +}