Redo explore page (#4491)
* Redo explore page, wip * Remove circle icons * Load more styling * Lower limit * Some styling tweaks * Abstract * Add tab, query, factor out * Revert unneeded change * Revert unneeded change v2 * Update copy * Load more styling * Header styles * The thin blue line * Make sure it's hairline * Update query keys * Border * Expand avis * Very load more copyzio/stable
parent
94c1f4968d
commit
36e976fe5c
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 285 B |
|
@ -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',
|
||||
})
|
||||
|
|
|
@ -190,8 +190,10 @@ export const KNOWN_AUTHED_ONLY_FEEDS = [
|
|||
|
||||
type GetPopularFeedsOptions = {limit?: number}
|
||||
|
||||
export function createGetPopularFeedsQueryKey(...args: any[]) {
|
||||
return ['getPopularFeeds', ...args]
|
||||
export function createGetPopularFeedsQueryKey(
|
||||
options?: GetPopularFeedsOptions,
|
||||
) {
|
||||
return ['getPopularFeeds', options]
|
||||
}
|
||||
|
||||
export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
||||
|
@ -299,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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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.did}
|
||||
// @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
|
||||
|
|
Loading…
Reference in New Issue