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
+ }),
+ })),
+ }
+}