diff --git a/assets/icons/arrowBottom_stroke2_corner0_rounded.svg b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..5f4a11e09a
--- /dev/null
+++ b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 4a9981a551..9e011fb66f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
- "version": "1.86.0",
+ "version": "1.87.0",
"private": true,
"engines": {
"node": ">=18"
diff --git a/patches/expo-haptics+12.8.1.patch b/patches/expo-haptics+13.0.1.patch
similarity index 96%
rename from patches/expo-haptics+12.8.1.patch
rename to patches/expo-haptics+13.0.1.patch
index a95b56f3be..9c7b9a6663 100644
--- a/patches/expo-haptics+12.8.1.patch
+++ b/patches/expo-haptics+13.0.1.patch
@@ -1,9 +1,9 @@
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
-index 26c52af..b949a4c 100644
+index 1520465..6ea988a 100644
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
-
+
private fun vibrate(type: HapticsVibrationType) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
diff --git a/patches/expo-updates+0.25.11.patch b/patches/expo-updates+0.25.14.patch
similarity index 96%
rename from patches/expo-updates+0.25.11.patch
rename to patches/expo-updates+0.25.14.patch
index 5f9eceef48..6fc4fc5fcf 100644
--- a/patches/expo-updates+0.25.11.patch
+++ b/patches/expo-updates+0.25.14.patch
@@ -1,11 +1,11 @@
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
-index b85291e..07a5d3c 100644
+index b85291e..546709d 100644
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
+++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
@@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
status = UpdateStatus.StatusPending
}
-
+
+ // Instead of relying on various hacks to get the correct format for the specific
+ // platform on the backend, we can just add this little patch..
+ let dateFormatter = DateFormatter()
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index a8bdb763d7..63f61ce856 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 (
page.feeds) || []
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 0a618ab3bb..dc907664e2 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 90ff54e91e..2981b41b45 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,18 +172,54 @@ 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
+]
-export function useGetPopularFeedsQuery(limit: number = 10) {
+type GetPopularFeedsOptions = {limit?: number}
+
+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,
QueryKey,
string | undefined
>({
- queryKey: useGetPopularFeedsQueryKey,
+ queryKey: createGetPopularFeedsQueryKey(options),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
limit,
@@ -192,7 +229,62 @@ export function useGetPopularFeedsQuery(limit: number = 10) {
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
+ select: useCallback(
+ (
+ data: InfiniteData,
+ ) => {
+ 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 adcbf4b502..fedd9b40f0 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 {
+ const queryDatas = queryClient.getQueriesData<
+ InfiniteData
+ >({
+ 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 59b8f7ed55..40251d43d7 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 77bdba51fd..28f29ec787 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},
]}>
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 76ff4268fd..612559455f 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -104,22 +104,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()
@@ -327,10 +311,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 +319,11 @@ 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,
+ })),
)
}
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
new file mode 100644
index 0000000000..f6e998838a
--- /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
+} & ViewStyleProp) {
+ const t = useTheme()
+
+ return (
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ )
+}
+
+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 (
+
+
+
+ )
+}
+
+type ExploreScreenItems =
+ | {
+ type: 'header'
+ key: string
+ title: string
+ description: string
+ style?: ViewStyleProp['style']
+ icon: React.ComponentType
+ }
+ | {
+ 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(() => {
+ 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 (
+
+ )
+ }
+ case 'profile': {
+ return (
+
+
+
+ )
+ }
+ case 'feed': {
+ return (
+
+
+
+ )
+ }
+ case 'loadMore': {
+ return
+ }
+ case 'profilePlaceholder': {
+ return
+ }
+ case 'feedPlaceholder': {
+ return
+ }
+ case 'error': {
+ return (
+
+
+
+
+
+ {item.message}
+
+
+ {item.error}
+
+
+
+
+ )
+ }
+ }
+ },
+ [t, hasSession, moderationOpts],
+ )
+
+ return (
+ 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 b6daf84b38..f1b0301d00 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 ? (
- }
- keyExtractor={item => item.did}
- // @ts-ignore web only -prf
- desktopFixedHeight
- contentContainerStyle={{paddingBottom: 200}}
- keyboardShouldPersistTaps="handled"
- keyboardDismissMode="on-drag"
- onEndReached={onEndReached}
- onEndReachedThreshold={2}
- />
- ) : (
-
-
-
-
- )
-}
-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 ? (
+ (
+
+ )}
+ keyExtractor={item => item.did}
+ // @ts-ignore web only -prf
+ desktopFixedHeight
+ contentContainerStyle={{paddingBottom: 100}}
+ />
+ ) : (
+
+ )}
+ >
+ ) : (
+
+ )
+}
+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 => {
),
},
+ {
+ title: _(msg`Feeds`),
+ component: (
+
+ ),
+ },
]
}, [_, query, activeTab])
@@ -408,26 +394,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
))}
) : hasSession ? (
-
-
-
- Suggested Follows
-
-
-
-
-
+
) : (