[Explore] Dynamic module ordering (#8066)
* Dynamic module ordering * [Explore] New headers, metrics (#8067) * new sticky headers * improve spacing between modules * view metric on modules * update metrics names * [Explore] Suggested accounts module (#8072) * use modern profile card, update load more * add tab bar * tabbed suggested accounts * [Explore] Discover feeds module (#8073) * cap number of feeds to 3 * change feed pin button
This commit is contained in:
committed by
Eric Bailey
parent
522eab8dcb
commit
52fe45ea41
+18
-14
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {GestureResponderEvent, View} from 'react-native'
|
import {type GestureResponderEvent, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
AppBskyGraphDefs,
|
type AppBskyGraphDefs,
|
||||||
AtUri,
|
AtUri,
|
||||||
RichText as RichTextApi,
|
RichText as RichTextApi,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
@@ -23,15 +23,14 @@ import * as Toast from '#/view/com/util/Toast'
|
|||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {RichText, RichTextProps} from '#/components/RichText'
|
import {RichText, type RichTextProps} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
view: AppBskyFeedDefs.GeneratorView
|
view: AppBskyFeedDefs.GeneratorView
|
||||||
@@ -294,14 +293,19 @@ function SaveButtonInner({
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
label={_(msg`Add this feed to your feeds`)}
|
label={_(msg`Add this feed to your feeds`)}
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost"
|
variant="solid"
|
||||||
color="secondary"
|
color={savedFeedConfig ? 'secondary' : 'primary'}
|
||||||
shape="square"
|
|
||||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
|
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
|
||||||
{savedFeedConfig ? (
|
{savedFeedConfig ? (
|
||||||
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
|
<>
|
||||||
|
{isPending && <ButtonIcon size="md" icon={Loader} />}
|
||||||
|
<ButtonText>Unpin Feed</ButtonText>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
|
<>
|
||||||
|
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
||||||
|
<ButtonText>Pin Feed</ButtonText>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {GestureResponderEvent, View} from 'react-native'
|
import {type GestureResponderEvent, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
ModerationOpts,
|
type ModerationOpts,
|
||||||
RichText as RichTextApi,
|
RichText as RichTextApi,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {LogEvents} from '#/lib/statsig/statsig'
|
import {type LogEvents} from '#/lib/statsig/statsig'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
@@ -18,13 +18,18 @@ import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
|
|||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonIcon,
|
||||||
|
type ButtonProps,
|
||||||
|
ButtonText,
|
||||||
|
} from '#/components/Button'
|
||||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function Default({
|
export function Default({
|
||||||
profile,
|
profile,
|
||||||
@@ -286,6 +291,7 @@ export type FollowButtonProps = {
|
|||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow']['logContext']
|
||||||
colorInverted?: boolean
|
colorInverted?: boolean
|
||||||
onFollow?: () => void
|
onFollow?: () => void
|
||||||
|
withIcon?: boolean
|
||||||
} & Partial<ButtonProps>
|
} & Partial<ButtonProps>
|
||||||
|
|
||||||
export function FollowButton(props: FollowButtonProps) {
|
export function FollowButton(props: FollowButtonProps) {
|
||||||
@@ -301,6 +307,7 @@ export function FollowButtonInner({
|
|||||||
onPress: onPressProp,
|
onPress: onPressProp,
|
||||||
onFollow,
|
onFollow,
|
||||||
colorInverted,
|
colorInverted,
|
||||||
|
withIcon = true,
|
||||||
...rest
|
...rest
|
||||||
}: FollowButtonProps) {
|
}: FollowButtonProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -386,7 +393,9 @@ export function FollowButtonInner({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
{...rest}
|
{...rest}
|
||||||
onPress={onPressUnfollow}>
|
onPress={onPressUnfollow}>
|
||||||
|
{withIcon && (
|
||||||
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||||
|
)}
|
||||||
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -397,7 +406,9 @@ export function FollowButtonInner({
|
|||||||
color={colorInverted ? 'secondary_inverted' : 'primary'}
|
color={colorInverted ? 'secondary_inverted' : 'primary'}
|
||||||
{...rest}
|
{...rest}
|
||||||
onPress={onPressFollow}>
|
onPress={onPressFollow}>
|
||||||
|
{withIcon && (
|
||||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||||
|
)}
|
||||||
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Animated, {
|
|||||||
LinearTransition,
|
LinearTransition,
|
||||||
ZoomInEasyDown,
|
ZoomInEasyDown,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
|
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
|||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
||||||
import {ListMethods} from '#/view/com/util/List'
|
import {type ListMethods} from '#/view/com/util/List'
|
||||||
import {
|
import {
|
||||||
popularInterests,
|
popularInterests,
|
||||||
useInterestsDisplayNames,
|
useInterestsDisplayNames,
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
tokens,
|
tokens,
|
||||||
useBreakpoints,
|
useBreakpoints,
|
||||||
useTheme,
|
useTheme,
|
||||||
ViewStyleProp,
|
type ViewStyleProp,
|
||||||
web,
|
web,
|
||||||
} from '#/alf'
|
} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
@@ -452,12 +452,14 @@ let Tabs = ({
|
|||||||
selectedInterest,
|
selectedInterest,
|
||||||
hasSearchText,
|
hasSearchText,
|
||||||
interestsDisplayNames,
|
interestsDisplayNames,
|
||||||
|
TabComponent = Tab,
|
||||||
}: {
|
}: {
|
||||||
onSelectTab: (tab: string) => void
|
onSelectTab: (tab: string) => void
|
||||||
interests: string[]
|
interests: string[]
|
||||||
selectedInterest: string
|
selectedInterest: string
|
||||||
hasSearchText: boolean
|
hasSearchText: boolean
|
||||||
interestsDisplayNames: Record<string, string>
|
interestsDisplayNames: Record<string, string>
|
||||||
|
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const listRef = useRef<ScrollView>(null)
|
const listRef = useRef<ScrollView>(null)
|
||||||
const [scrollX, setScrollX] = useState(0)
|
const [scrollX, setScrollX] = useState(0)
|
||||||
@@ -532,7 +534,7 @@ let Tabs = ({
|
|||||||
{interests.map((interest, i) => {
|
{interests.map((interest, i) => {
|
||||||
const active = interest === selectedInterest && !hasSearchText
|
const active = interest === selectedInterest && !hasSearchText
|
||||||
return (
|
return (
|
||||||
<Tab
|
<TabComponent
|
||||||
key={interest}
|
key={interest}
|
||||||
onSelectTab={handleSelectTab}
|
onSelectTab={handleSelectTab}
|
||||||
active={active}
|
active={active}
|
||||||
@@ -547,6 +549,7 @@ let Tabs = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Tabs = memo(Tabs)
|
Tabs = memo(Tabs)
|
||||||
|
export {Tabs}
|
||||||
|
|
||||||
let Tab = ({
|
let Tab = ({
|
||||||
onSelectTab,
|
onSelectTab,
|
||||||
@@ -822,7 +825,7 @@ function Empty({message}: {message: string}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function boostInterests(boosts?: string[]) {
|
export function boostInterests(boosts?: string[]) {
|
||||||
return (_a: string, _b: string) => {
|
return (_a: string, _b: string) => {
|
||||||
const indexA = boosts?.indexOf(_a) ?? -1
|
const indexA = boosts?.indexOf(_a) ?? -1
|
||||||
const indexB = boosts?.indexOf(_b) ?? -1
|
const indexB = boosts?.indexOf(_b) ?? -1
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {StyleSheet, TextProps} from 'react-native'
|
import {StyleSheet, type TextProps} from 'react-native'
|
||||||
import type {PathProps, SvgProps} from 'react-native-svg'
|
import {type PathProps, type SvgProps} from 'react-native-svg'
|
||||||
import {Defs, LinearGradient, Stop} from 'react-native-svg'
|
import {Defs, LinearGradient, Stop} from 'react-native-svg'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export const sizes = {
|
|||||||
lg: 24,
|
lg: 24,
|
||||||
xl: 28,
|
xl: 28,
|
||||||
'2xl': 32,
|
'2xl': 32,
|
||||||
}
|
} as const
|
||||||
|
|
||||||
export function useCommonSVGProps(props: Props) {
|
export function useCommonSVGProps(props: Props) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type Gate =
|
|||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
| 'debug_show_feedcontext'
|
||||||
| 'debug_subscriptions'
|
| 'debug_subscriptions'
|
||||||
|
| 'explore_show_suggested_feeds'
|
||||||
| 'old_postonboarding'
|
| 'old_postonboarding'
|
||||||
| 'onboarding_add_video_feed'
|
| 'onboarding_add_video_feed'
|
||||||
| 'remove_show_latest_button'
|
| 'remove_show_latest_button'
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
|
|
||||||
export type MetricEvents = {
|
export type MetricEvents = {
|
||||||
// App events
|
// App events
|
||||||
init: {
|
init: {
|
||||||
@@ -202,6 +204,7 @@ export type MetricEvents = {
|
|||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
| 'PostOnboardingFindFollows'
|
| 'PostOnboardingFindFollows'
|
||||||
| 'ImmersiveVideo'
|
| 'ImmersiveVideo'
|
||||||
|
| 'ExploreSuggestedAccounts'
|
||||||
}
|
}
|
||||||
'suggestedUser:follow': {
|
'suggestedUser:follow': {
|
||||||
logContext:
|
logContext:
|
||||||
@@ -239,6 +242,7 @@ export type MetricEvents = {
|
|||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
| 'PostOnboardingFindFollows'
|
| 'PostOnboardingFindFollows'
|
||||||
| 'ImmersiveVideo'
|
| 'ImmersiveVideo'
|
||||||
|
| 'ExploreSuggestedAccounts'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||||
@@ -318,6 +322,22 @@ export type MetricEvents = {
|
|||||||
context: 'interstitial:discover' | 'interstitial:explore' | 'feed'
|
context: 'interstitial:discover' | 'interstitial:explore' | 'feed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
'explore:module:seen': {
|
||||||
|
module:
|
||||||
|
| 'trendingTopics'
|
||||||
|
| 'trendingVideos'
|
||||||
|
| 'suggestedAccounts'
|
||||||
|
| 'suggestedFeeds'
|
||||||
|
| 'suggestedStarterPacks'
|
||||||
|
| `feed:${FeedDescriptor}`
|
||||||
|
}
|
||||||
|
'explore:module:searchButtonPress': {
|
||||||
|
module: 'suggestedAccounts' | 'suggestedFeeds'
|
||||||
|
}
|
||||||
|
'explore:suggestedAccounts:tabPressed': {
|
||||||
|
tab: string
|
||||||
|
}
|
||||||
|
|
||||||
'progressGuide:hide': {}
|
'progressGuide:hide': {}
|
||||||
'progressGuide:followDialog:open': {}
|
'progressGuide:followDialog:open': {}
|
||||||
|
|
||||||
|
|||||||
+266
-245
@@ -1,135 +1,49 @@
|
|||||||
import {useCallback, useMemo} from 'react'
|
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
|
||||||
import {
|
import {type AppBskyActorDefs, type AppBskyFeedDefs} from '@atproto/api'
|
||||||
type AppBskyActorDefs,
|
import {msg} from '@lingui/macro'
|
||||||
type AppBskyFeedDefs,
|
|
||||||
moderateProfile,
|
|
||||||
type ModerationDecision,
|
|
||||||
type ModerationOpts,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {type MetricEvents} from '#/logger/metrics'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
||||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
|
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
|
||||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||||
import {List} from '#/view/com/util/List'
|
import {List} from '#/view/com/util/List'
|
||||||
import {
|
import {
|
||||||
FeedFeedLoadingPlaceholder,
|
FeedFeedLoadingPlaceholder,
|
||||||
ProfileCardFeedLoadingPlaceholder,
|
ProfileCardFeedLoadingPlaceholder,
|
||||||
} from '#/view/com/util/LoadingPlaceholder'
|
} from '#/view/com/util/LoadingPlaceholder'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
|
||||||
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
|
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
|
||||||
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
|
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
|
||||||
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
|
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
|
||||||
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import * as FeedCard from '#/components/FeedCard'
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow'
|
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
||||||
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
|
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as ModuleHeader from './components/ModuleHeader'
|
||||||
|
import {
|
||||||
|
SuggestedAccountsTabBar,
|
||||||
|
SuggestedProfileCard,
|
||||||
|
} from './modules/ExploreSuggestedAccounts'
|
||||||
|
|
||||||
function SuggestedItemsHeader({
|
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
|
||||||
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_heavy, t.atoms.text]}>{title}</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoadMoreItem =
|
|
||||||
| {
|
|
||||||
type: 'profile'
|
|
||||||
key: string
|
|
||||||
avatar: string | undefined
|
|
||||||
moderation: ModerationDecision
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'feed'
|
|
||||||
key: string
|
|
||||||
avatar: string | undefined
|
|
||||||
moderation: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadMore({
|
|
||||||
item,
|
|
||||||
moderationOpts,
|
|
||||||
}: {
|
|
||||||
item: ExploreScreenItems & {type: 'loadMore'}
|
|
||||||
moderationOpts?: ModerationOpts
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const items: LoadMoreItem[] = useMemo(() => {
|
|
||||||
return item.items
|
|
||||||
.map(_item => {
|
|
||||||
let loadMoreItem: LoadMoreItem | undefined
|
|
||||||
if (_item.type === 'profile') {
|
|
||||||
loadMoreItem = {
|
|
||||||
type: 'profile',
|
|
||||||
key: _item.profile.did,
|
|
||||||
avatar: _item.profile.avatar,
|
|
||||||
moderation: moderateProfile(_item.profile, moderationOpts!),
|
|
||||||
}
|
|
||||||
} else if (_item.type === 'feed') {
|
|
||||||
loadMoreItem = {
|
|
||||||
type: 'feed',
|
|
||||||
key: _item.feed.uri,
|
|
||||||
avatar: _item.feed.avatar,
|
|
||||||
moderation: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return loadMoreItem
|
|
||||||
})
|
|
||||||
.filter(n => !!n)
|
|
||||||
}, [item.items, moderationOpts])
|
|
||||||
|
|
||||||
if (items.length === 0) return null
|
|
||||||
|
|
||||||
const type = items[0].type
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[]}>
|
<View style={[a.pb_2xl]}>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Load more`)}
|
label={_(msg`Load more`)}
|
||||||
onPress={item.onLoadMore}
|
onPress={item.onLoadMore}
|
||||||
@@ -140,93 +54,27 @@ function LoadMore({
|
|||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
|
a.gap_sm,
|
||||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||||
]}>
|
]}>
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.relative,
|
|
||||||
{
|
|
||||||
height: 32,
|
|
||||||
width: 32 + 15 * items.length,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
left: 0,
|
|
||||||
borderWidth: 1,
|
|
||||||
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={[
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
left: (i + 1) * 15,
|
|
||||||
borderWidth: 1,
|
|
||||||
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')}
|
|
||||||
type="user"
|
|
||||||
/>
|
|
||||||
) : _item.type === 'feed' ? (
|
|
||||||
<UserAvatar
|
|
||||||
size={28}
|
|
||||||
avatar={_item.avatar}
|
|
||||||
type="algo"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.pl_sm,
|
|
||||||
a.leading_snug,
|
a.leading_snug,
|
||||||
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
|
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
{type === 'profile' ? (
|
{item.message}
|
||||||
<Trans>Load more suggested follows</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Load more suggested feeds</Trans>
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{item.isLoadingMore ? (
|
||||||
<View style={[a.flex_1, a.align_end]}>
|
<Loader size="sm" />
|
||||||
{item.isLoadingMore && <Loader size="lg" />}
|
) : (
|
||||||
</View>
|
<ChevronDownIcon
|
||||||
|
size="sm"
|
||||||
|
style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -235,13 +83,31 @@ function LoadMore({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExploreScreenItems =
|
type ExploreScreenItems =
|
||||||
|
| {
|
||||||
|
type: 'topBorder'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'header'
|
type: 'header'
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
|
||||||
style?: ViewStyleProp['style']
|
|
||||||
icon: React.ComponentType<SVGIconProps>
|
icon: React.ComponentType<SVGIconProps>
|
||||||
|
searchButton?: {
|
||||||
|
label: string
|
||||||
|
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
|
||||||
|
tab: 'user' | 'profile' | 'feed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tabbedHeader'
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
icon: React.ComponentType<SVGIconProps>
|
||||||
|
searchButton?: {
|
||||||
|
label: string
|
||||||
|
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
|
||||||
|
tab: 'user' | 'profile' | 'feed'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'trendingTopics'
|
type: 'trendingTopics'
|
||||||
@@ -269,9 +135,9 @@ type ExploreScreenItems =
|
|||||||
| {
|
| {
|
||||||
type: 'loadMore'
|
type: 'loadMore'
|
||||||
key: string
|
key: string
|
||||||
|
message: string
|
||||||
isLoadingMore: boolean
|
isLoadingMore: boolean
|
||||||
onLoadMore: () => void
|
onLoadMore: () => void
|
||||||
items: ExploreScreenItems[]
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'profilePlaceholder'
|
type: 'profilePlaceholder'
|
||||||
@@ -288,19 +154,38 @@ type ExploreScreenItems =
|
|||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Explore() {
|
export function Explore({
|
||||||
|
focusSearchInput,
|
||||||
|
}: {
|
||||||
|
focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
|
||||||
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {data: preferences, error: preferencesError} = usePreferencesQuery()
|
const {data: preferences, error: preferencesError} = usePreferencesQuery()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
const gate = useGate()
|
||||||
|
const guide = useProgressGuide('follow-10')
|
||||||
|
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
|
||||||
const {
|
const {
|
||||||
data: profiles,
|
data: suggestedProfiles,
|
||||||
hasNextPage: hasNextProfilesPage,
|
hasNextPage: hasNextSuggestedProfilesPage,
|
||||||
isLoading: isLoadingProfiles,
|
isLoading: isLoadingSuggestedProfiles,
|
||||||
isFetchingNextPage: isFetchingNextProfilesPage,
|
isFetchingNextPage: isFetchingNextSuggestedProfilesPage,
|
||||||
error: profilesError,
|
error: suggestedProfilesError,
|
||||||
fetchNextPage: fetchNextProfilesPage,
|
fetchNextPage: fetchNextSuggestedProfilesPage,
|
||||||
} = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10})
|
} = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10})
|
||||||
|
const {
|
||||||
|
data: interestProfiles,
|
||||||
|
hasNextPage: hasNextInterestProfilesPage,
|
||||||
|
isLoading: isLoadingInterestProfiles,
|
||||||
|
isFetchingNextPage: isFetchingNextInterestProfilesPage,
|
||||||
|
error: interestProfilesError,
|
||||||
|
fetchNextPage: fetchNextInterestProfilesPage,
|
||||||
|
} = useActorSearchPaginated({
|
||||||
|
query: selectedInterest || '',
|
||||||
|
enabled: !!selectedInterest,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
const {
|
const {
|
||||||
data: feeds,
|
data: feeds,
|
||||||
hasNextPage: hasNextFeedsPage,
|
hasNextPage: hasNextFeedsPage,
|
||||||
@@ -310,6 +195,24 @@ export function Explore() {
|
|||||||
fetchNextPage: fetchNextFeedsPage,
|
fetchNextPage: fetchNextFeedsPage,
|
||||||
} = useGetPopularFeedsQuery({limit: 10})
|
} = useGetPopularFeedsQuery({limit: 10})
|
||||||
|
|
||||||
|
const profiles: typeof suggestedProfiles & typeof interestProfiles =
|
||||||
|
!selectedInterest ? suggestedProfiles : interestProfiles
|
||||||
|
const hasNextProfilesPage = !selectedInterest
|
||||||
|
? hasNextSuggestedProfilesPage
|
||||||
|
: hasNextInterestProfilesPage
|
||||||
|
const isLoadingProfiles = !selectedInterest
|
||||||
|
? isLoadingSuggestedProfiles
|
||||||
|
: isLoadingInterestProfiles
|
||||||
|
const isFetchingNextProfilesPage = !selectedInterest
|
||||||
|
? isFetchingNextSuggestedProfilesPage
|
||||||
|
: isFetchingNextInterestProfilesPage
|
||||||
|
const profilesError = !selectedInterest
|
||||||
|
? suggestedProfilesError
|
||||||
|
: interestProfilesError
|
||||||
|
const fetchNextProfilesPage = !selectedInterest
|
||||||
|
? fetchNextSuggestedProfilesPage
|
||||||
|
: fetchNextInterestProfilesPage
|
||||||
|
|
||||||
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
||||||
const onLoadMoreProfiles = useCallback(async () => {
|
const onLoadMoreProfiles = useCallback(async () => {
|
||||||
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
|
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
|
||||||
@@ -327,8 +230,13 @@ export function Explore() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
|
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
|
||||||
|
const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false)
|
||||||
const onLoadMoreFeeds = useCallback(async () => {
|
const onLoadMoreFeeds = useCallback(async () => {
|
||||||
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
|
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
|
||||||
|
if (!hasPressedLoadMoreFeeds) {
|
||||||
|
setHasPressedLoadMoreFeeds(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fetchNextFeedsPage()
|
await fetchNextFeedsPage()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -339,11 +247,20 @@ export function Explore() {
|
|||||||
hasNextFeedsPage,
|
hasNextFeedsPage,
|
||||||
feedsError,
|
feedsError,
|
||||||
fetchNextFeedsPage,
|
fetchNextFeedsPage,
|
||||||
|
hasPressedLoadMoreFeeds,
|
||||||
])
|
])
|
||||||
|
|
||||||
const items = useMemo<ExploreScreenItems[]>(() => {
|
const items = useMemo<ExploreScreenItems[]>(() => {
|
||||||
const i: ExploreScreenItems[] = []
|
const i: ExploreScreenItems[] = []
|
||||||
|
|
||||||
|
const addTopBorder = () => {
|
||||||
|
i.push({
|
||||||
|
type: 'topBorder',
|
||||||
|
key: `top-border`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTrendingTopicsModule = () => {
|
||||||
i.push({
|
i.push({
|
||||||
type: 'trendingTopics',
|
type: 'trendingTopics',
|
||||||
key: `trending-topics`,
|
key: `trending-topics`,
|
||||||
@@ -356,18 +273,22 @@ export function Explore() {
|
|||||||
// key: `trending-videos`,
|
// key: `trending-videos`,
|
||||||
// })
|
// })
|
||||||
// }
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSuggestedFollowsModule = () => {
|
||||||
i.push({
|
i.push({
|
||||||
type: 'header',
|
type: 'tabbedHeader',
|
||||||
key: 'suggested-follows-header',
|
key: 'suggested-accounts-header',
|
||||||
title: _(msg`Suggested accounts`),
|
title: _(msg`Suggested Accounts`),
|
||||||
description: _(
|
|
||||||
msg`Follow more accounts to get connected to your interests and build your network.`,
|
|
||||||
),
|
|
||||||
icon: Person,
|
icon: Person,
|
||||||
|
searchButton: {
|
||||||
|
label: _(msg`Search for more accounts`),
|
||||||
|
metricsTag: 'suggestedAccounts',
|
||||||
|
tab: 'user',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (profiles) {
|
if (profiles && moderationOpts) {
|
||||||
// Currently the responses contain duplicate items.
|
// Currently the responses contain duplicate items.
|
||||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
// Needs to be fixed on backend, but let's dedupe to be safe.
|
||||||
let seen = new Set()
|
let seen = new Set()
|
||||||
@@ -386,20 +307,20 @@ export function Explore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNextProfilesPage) {
|
if (profileItems.length === 0) {
|
||||||
// splice off 3 as previews if we have a next page
|
// no items! remove the header
|
||||||
const previews = profileItems.splice(-3)
|
i.pop()
|
||||||
// push remainder
|
} else {
|
||||||
i.push(...profileItems)
|
i.push(...profileItems)
|
||||||
|
if (hasNextProfilesPage) {
|
||||||
i.push({
|
i.push({
|
||||||
type: 'loadMore',
|
type: 'loadMore',
|
||||||
key: 'loadMoreProfiles',
|
key: 'loadMoreProfiles',
|
||||||
|
message: _(msg`Load more suggested accounts`),
|
||||||
isLoadingMore: isLoadingMoreProfiles,
|
isLoadingMore: isLoadingMoreProfiles,
|
||||||
onLoadMore: onLoadMoreProfiles,
|
onLoadMore: onLoadMoreProfiles,
|
||||||
items: previews,
|
|
||||||
})
|
})
|
||||||
} else {
|
}
|
||||||
i.push(...profileItems)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (profilesError) {
|
if (profilesError) {
|
||||||
@@ -413,16 +334,19 @@ export function Explore() {
|
|||||||
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
|
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSuggestedFeedsModule = () => {
|
||||||
i.push({
|
i.push({
|
||||||
type: 'header',
|
type: 'header',
|
||||||
key: 'suggested-feeds-header',
|
key: 'suggested-feeds-header',
|
||||||
title: _(msg`Discover new feeds`),
|
title: _(msg`Discover Feeds`),
|
||||||
description: _(
|
|
||||||
msg`Choose your own timeline! Feeds built by the community help you find content you love.`,
|
|
||||||
),
|
|
||||||
style: [a.pt_5xl],
|
|
||||||
icon: ListSparkle,
|
icon: ListSparkle,
|
||||||
|
searchButton: {
|
||||||
|
label: _(msg`Search for more feeds`),
|
||||||
|
metricsTag: 'suggestedFeeds',
|
||||||
|
tab: 'feed',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (feeds && preferences) {
|
if (feeds && preferences) {
|
||||||
@@ -458,18 +382,27 @@ export function Explore() {
|
|||||||
message: _(msg`Failed to load feeds preferences`),
|
message: _(msg`Failed to load feeds preferences`),
|
||||||
error: cleanError(preferencesError),
|
error: cleanError(preferencesError),
|
||||||
})
|
})
|
||||||
} else if (hasNextFeedsPage) {
|
} else {
|
||||||
const preview = feedItems.splice(-3)
|
if (feedItems.length === 0) {
|
||||||
|
i.pop()
|
||||||
|
} else {
|
||||||
|
// This query doesn't follow the limit very well, so the first press of the
|
||||||
|
// load more button just unslices the array back to ~10 items
|
||||||
|
if (!hasPressedLoadMoreFeeds) {
|
||||||
|
i.push(...feedItems.slice(0, 3))
|
||||||
|
} else {
|
||||||
i.push(...feedItems)
|
i.push(...feedItems)
|
||||||
|
}
|
||||||
|
if (hasNextFeedsPage) {
|
||||||
i.push({
|
i.push({
|
||||||
type: 'loadMore',
|
type: 'loadMore',
|
||||||
key: 'loadMoreFeeds',
|
key: 'loadMoreFeeds',
|
||||||
|
message: _(msg`Load more suggested feeds`),
|
||||||
isLoadingMore: isLoadingMoreFeeds,
|
isLoadingMore: isLoadingMoreFeeds,
|
||||||
onLoadMore: onLoadMoreFeeds,
|
onLoadMore: onLoadMoreFeeds,
|
||||||
items: preview,
|
|
||||||
})
|
})
|
||||||
} else {
|
}
|
||||||
i.push(...feedItems)
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (feedsError) {
|
if (feedsError) {
|
||||||
@@ -490,6 +423,23 @@ export function Explore() {
|
|||||||
i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'})
|
i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic module ordering
|
||||||
|
|
||||||
|
addTopBorder()
|
||||||
|
|
||||||
|
if (guide?.guide === 'follow-10' && !guide.isComplete) {
|
||||||
|
addSuggestedFollowsModule()
|
||||||
|
addTrendingTopicsModule()
|
||||||
|
} else {
|
||||||
|
addTrendingTopicsModule()
|
||||||
|
addSuggestedFollowsModule()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gate('explore_show_suggested_feeds')) {
|
||||||
|
addSuggestedFeedsModule()
|
||||||
|
}
|
||||||
|
|
||||||
return i
|
return i
|
||||||
}, [
|
}, [
|
||||||
@@ -506,19 +456,61 @@ export function Explore() {
|
|||||||
preferencesError,
|
preferencesError,
|
||||||
hasNextProfilesPage,
|
hasNextProfilesPage,
|
||||||
hasNextFeedsPage,
|
hasNextFeedsPage,
|
||||||
|
guide,
|
||||||
|
gate,
|
||||||
|
moderationOpts,
|
||||||
|
hasPressedLoadMoreFeeds,
|
||||||
])
|
])
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({item, index}: {item: ExploreScreenItems; index: number}) => {
|
({item, index}: {item: ExploreScreenItems; index: number}) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
|
case 'topBorder':
|
||||||
|
return (
|
||||||
|
<View style={[a.w_full, t.atoms.border_contrast_low, a.border_t]} />
|
||||||
|
)
|
||||||
case 'header': {
|
case 'header': {
|
||||||
return (
|
return (
|
||||||
<SuggestedItemsHeader
|
<ModuleHeader.Container>
|
||||||
title={item.title}
|
<ModuleHeader.Icon icon={item.icon} />
|
||||||
description={item.description}
|
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
|
||||||
style={item.style}
|
{item.searchButton && (
|
||||||
icon={item.icon}
|
<ModuleHeader.SearchButton
|
||||||
|
{...item.searchButton}
|
||||||
|
onPress={() =>
|
||||||
|
focusSearchInput(item.searchButton?.tab || 'user')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</ModuleHeader.Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'tabbedHeader': {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.pb_md,
|
||||||
|
t.atoms.bg,
|
||||||
|
]}>
|
||||||
|
<ModuleHeader.Container style={a.border_transparent}>
|
||||||
|
<ModuleHeader.Icon icon={item.icon} />
|
||||||
|
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
|
||||||
|
{item.searchButton && (
|
||||||
|
<ModuleHeader.SearchButton
|
||||||
|
{...item.searchButton}
|
||||||
|
onPress={() =>
|
||||||
|
focusSearchInput(item.searchButton?.tab || 'user')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModuleHeader.Container>
|
||||||
|
<SuggestedAccountsTabBar
|
||||||
|
selectedInterest={selectedInterest}
|
||||||
|
onSelectInterest={setSelectedInterest}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'trendingTopics': {
|
case 'trendingTopics': {
|
||||||
@@ -532,29 +524,12 @@ export function Explore() {
|
|||||||
}
|
}
|
||||||
case 'profile': {
|
case 'profile': {
|
||||||
return (
|
return (
|
||||||
<View style={[a.border_b, t.atoms.border_contrast_low]}>
|
<SuggestedProfileCard
|
||||||
<ProfileCardWithFollowBtn
|
|
||||||
profile={item.profile}
|
profile={item.profile}
|
||||||
noBg
|
moderationOpts={moderationOpts!}
|
||||||
noBorder
|
recId={item.recId}
|
||||||
showKnownFollowers
|
position={index}
|
||||||
onPress={() => {
|
|
||||||
logger.metric('suggestedUser:press', {
|
|
||||||
logContext: 'Explore',
|
|
||||||
recId: item.recId,
|
|
||||||
position: index,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onFollow={() => {
|
|
||||||
logger.metric('suggestedUser:follow', {
|
|
||||||
logContext: 'Explore',
|
|
||||||
location: 'Card',
|
|
||||||
recId: item.recId,
|
|
||||||
position: index,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'feed': {
|
case 'feed': {
|
||||||
@@ -571,7 +546,7 @@ export function Explore() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'loadMore': {
|
case 'loadMore': {
|
||||||
return <LoadMore item={item} moderationOpts={moderationOpts} />
|
return <LoadMore item={item} />
|
||||||
}
|
}
|
||||||
case 'profilePlaceholder': {
|
case 'profilePlaceholder': {
|
||||||
return <ProfileCardFeedLoadingPlaceholder />
|
return <ProfileCardFeedLoadingPlaceholder />
|
||||||
@@ -616,11 +591,50 @@ export function Explore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t, moderationOpts],
|
[t, focusSearchInput, moderationOpts, selectedInterest],
|
||||||
|
)
|
||||||
|
|
||||||
|
const stickyHeaderIndices = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce(
|
||||||
|
(acc, curr) =>
|
||||||
|
['topBorder', 'header', 'tabbedHeader'].includes(curr.type)
|
||||||
|
? acc.concat(items.indexOf(curr))
|
||||||
|
: acc,
|
||||||
|
[] as number[],
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
|
// track headers and report module viewability
|
||||||
|
const alreadyReportedRef = useRef<Map<string, string>>(new Map())
|
||||||
|
const onViewableItemsChanged = useCallback(
|
||||||
|
({
|
||||||
|
viewableItems,
|
||||||
|
}: {
|
||||||
|
viewableItems: ViewToken<ExploreScreenItems>[]
|
||||||
|
changed: ViewToken<ExploreScreenItems>[]
|
||||||
|
}) => {
|
||||||
|
for (const {item} of viewableItems.filter(vi => vi.isViewable)) {
|
||||||
|
let module: MetricEvents['explore:module:seen']['module']
|
||||||
|
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
|
||||||
|
module = item.type
|
||||||
|
} else if (item.type === 'profile') {
|
||||||
|
module = 'suggestedAccounts'
|
||||||
|
} else if (item.type === 'feed') {
|
||||||
|
module = 'suggestedFeeds'
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!alreadyReportedRef.current.has(module)) {
|
||||||
|
alreadyReportedRef.current.set(module, module)
|
||||||
|
logger.metric('explore:module:seen', {module})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
// note: actually not a screen, instead it's nested within
|
|
||||||
// the search screen. so we don't need Layout.Screen
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
data={items}
|
data={items}
|
||||||
@@ -630,6 +644,13 @@ export function Explore() {
|
|||||||
contentContainerStyle={{paddingBottom: 100}}
|
contentContainerStyle={{paddingBottom: 100}}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
|
stickyHeaderIndices={stickyHeaderIndices}
|
||||||
|
viewabilityConfig={viewabilityConfig}
|
||||||
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewabilityConfig: ViewabilityConfig = {
|
||||||
|
itemVisiblePercentThreshold: 100,
|
||||||
|
}
|
||||||
|
|||||||
@@ -266,6 +266,10 @@ export function SearchScreenShell({
|
|||||||
}
|
}
|
||||||
}, [setShowAutocomplete])
|
}, [setShowAutocomplete])
|
||||||
|
|
||||||
|
const focusSearchInput = useCallback(() => {
|
||||||
|
textInput.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const showHeader = !gtMobile || navButton !== 'menu'
|
const showHeader = !gtMobile || navButton !== 'menu'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -399,6 +403,7 @@ export function SearchScreenShell({
|
|||||||
query={query}
|
query={query}
|
||||||
queryWithParams={queryWithParams}
|
queryWithParams={queryWithParams}
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
|
focusSearchInput={focusSearchInput}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</Layout.Screen>
|
</Layout.Screen>
|
||||||
@@ -409,10 +414,12 @@ let SearchScreenInner = ({
|
|||||||
query,
|
query,
|
||||||
queryWithParams,
|
queryWithParams,
|
||||||
headerHeight,
|
headerHeight,
|
||||||
|
focusSearchInput,
|
||||||
}: {
|
}: {
|
||||||
query: string
|
query: string
|
||||||
queryWithParams: string
|
queryWithParams: string
|
||||||
headerHeight: number
|
headerHeight: number
|
||||||
|
focusSearchInput: () => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
@@ -438,7 +445,7 @@ let SearchScreenInner = ({
|
|||||||
onPageSelected={onPageSelected}
|
onPageSelected={onPageSelected}
|
||||||
/>
|
/>
|
||||||
) : hasSession ? (
|
) : hasSession ? (
|
||||||
<Explore />
|
<Explore focusSearchInput={focusSearchInput} />
|
||||||
) : (
|
) : (
|
||||||
<Layout.Center>
|
<Layout.Center>
|
||||||
<View style={a.flex_1}>
|
<View style={a.flex_1}>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {
|
||||||
|
atoms as a,
|
||||||
|
native,
|
||||||
|
useGutters,
|
||||||
|
useTheme,
|
||||||
|
type ViewStyleProp,
|
||||||
|
} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {sizes as iconSizes} from '#/components/icons/common'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {Text, type TextProps} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function Container({
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {children: React.ReactNode} & ViewStyleProp) {
|
||||||
|
const t = useTheme()
|
||||||
|
const gutters = useGutters([0, 'base'])
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
gutters,
|
||||||
|
a.pt_2xs,
|
||||||
|
a.pb_sm,
|
||||||
|
a.gap_sm,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({
|
||||||
|
icon: Comp,
|
||||||
|
size = 'lg',
|
||||||
|
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
|
||||||
|
const iconSize = iconSizes[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
|
||||||
|
<Comp width={iconSize} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TitleText({style, ...props}: TextProps) {
|
||||||
|
return <Text style={[a.font_bold, a.flex_1, a.text_xl, style]} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchButton({
|
||||||
|
label,
|
||||||
|
metricsTag,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
metricsTag: 'suggestedAccounts' | 'suggestedFeeds'
|
||||||
|
onPress?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={label}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
PressableComponent={native(PressableScale)}
|
||||||
|
onPress={() => {
|
||||||
|
logger.metric('explore:module:searchButtonPress', {module: metricsTag})
|
||||||
|
onPress?.()
|
||||||
|
}}>
|
||||||
|
<ButtonIcon icon={SearchIcon} size="lg" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import {memo} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {type ModerationOpts} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
|
import {
|
||||||
|
popularInterests,
|
||||||
|
useInterestsDisplayNames,
|
||||||
|
} from '#/screens/Onboarding/state'
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export function SuggestedAccountsTabBar({
|
||||||
|
selectedInterest,
|
||||||
|
onSelectInterest,
|
||||||
|
}: {
|
||||||
|
selectedInterest: string | null
|
||||||
|
onSelectInterest: (interest: string | null) => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const interestsDisplayNames = useInterestsDisplayNames()
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const personalizedInterests = preferences?.interests?.tags
|
||||||
|
const interests = Object.keys(interestsDisplayNames)
|
||||||
|
.sort(boostInterests(popularInterests))
|
||||||
|
.sort(boostInterests(personalizedInterests))
|
||||||
|
return (
|
||||||
|
<BlockDrawerGesture>
|
||||||
|
<Tabs
|
||||||
|
interests={['all', ...interests]}
|
||||||
|
selectedInterest={selectedInterest || 'all'}
|
||||||
|
onSelectTab={tab => {
|
||||||
|
logger.metric('explore:suggestedAccounts:tabPressed', {tab: tab})
|
||||||
|
onSelectInterest(tab === 'all' ? null : tab)
|
||||||
|
}}
|
||||||
|
hasSearchText={false}
|
||||||
|
interestsDisplayNames={{
|
||||||
|
all: _(msg`All`),
|
||||||
|
...interestsDisplayNames,
|
||||||
|
}}
|
||||||
|
TabComponent={Tab}
|
||||||
|
/>
|
||||||
|
</BlockDrawerGesture>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let Tab = ({
|
||||||
|
onSelectTab,
|
||||||
|
interest,
|
||||||
|
active,
|
||||||
|
index,
|
||||||
|
interestsDisplayName,
|
||||||
|
onLayout,
|
||||||
|
}: {
|
||||||
|
onSelectTab: (index: number) => void
|
||||||
|
interest: string
|
||||||
|
active: boolean
|
||||||
|
index: number
|
||||||
|
interestsDisplayName: string
|
||||||
|
onLayout: (index: number, x: number, width: number) => void
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const activeText = active ? _(msg` (active)`) : ''
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={interest}
|
||||||
|
onLayout={e =>
|
||||||
|
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
|
||||||
|
}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
|
||||||
|
onPress={() => onSelectTab(index)}>
|
||||||
|
{({hovered, pressed, focused}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_sm,
|
||||||
|
a.border,
|
||||||
|
active || hovered || pressed || focused
|
||||||
|
? [
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
|
||||||
|
]
|
||||||
|
: [t.atoms.bg, t.atoms.border_contrast_low],
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
/* TODO: medium weight */
|
||||||
|
active || hovered || pressed || focused
|
||||||
|
? t.atoms.text
|
||||||
|
: t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{interestsDisplayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Tab = memo(Tab)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile card for suggested accounts. Note: border is on the bottom edge
|
||||||
|
*/
|
||||||
|
let SuggestedProfileCard = ({
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
recId,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
recId?: number
|
||||||
|
position: number
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<ProfileCard.Link
|
||||||
|
profile={profile}
|
||||||
|
style={[a.flex_1]}
|
||||||
|
onPress={() => {
|
||||||
|
logger.metric('suggestedUser:press', {
|
||||||
|
logContext: 'Explore',
|
||||||
|
recId,
|
||||||
|
position,
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.py_md,
|
||||||
|
a.px_lg,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.flex_1,
|
||||||
|
]}>
|
||||||
|
<ProfileCard.Outer>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.NameAndHandle
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.FollowButton
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
withIcon={false}
|
||||||
|
logContext="ExploreSuggestedAccounts"
|
||||||
|
onFollow={() => {
|
||||||
|
logger.metric('suggestedUser:follow', {
|
||||||
|
logContext: 'Explore',
|
||||||
|
location: 'Card',
|
||||||
|
recId,
|
||||||
|
position,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
<ProfileCard.Description profile={profile} numberOfLines={3} />
|
||||||
|
</ProfileCard.Outer>
|
||||||
|
</View>
|
||||||
|
</ProfileCard.Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SuggestedProfileCard = memo(SuggestedProfileCard)
|
||||||
|
export {SuggestedProfileCard}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
|
|
||||||
import {
|
import {
|
||||||
InfiniteData,
|
type AppBskyActorDefs,
|
||||||
|
type AppBskyActorSearchActors,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
QueryClient,
|
type QueryClient,
|
||||||
QueryKey,
|
type QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
@@ -15,7 +18,11 @@ const RQKEY_ROOT = 'actor-search'
|
|||||||
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
||||||
|
|
||||||
const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated`
|
const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated`
|
||||||
export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query]
|
export const RQKEY_PAGINATED = (query: string, limit?: number) => [
|
||||||
|
RQKEY_ROOT_PAGINATED,
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
]
|
||||||
|
|
||||||
export function useActorSearch({
|
export function useActorSearch({
|
||||||
query,
|
query,
|
||||||
@@ -42,10 +49,12 @@ export function useActorSearchPaginated({
|
|||||||
query,
|
query,
|
||||||
enabled,
|
enabled,
|
||||||
maintainData,
|
maintainData,
|
||||||
|
limit = 25,
|
||||||
}: {
|
}: {
|
||||||
query: string
|
query: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
maintainData?: boolean
|
maintainData?: boolean
|
||||||
|
limit?: number
|
||||||
}) {
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
@@ -56,11 +65,11 @@ export function useActorSearchPaginated({
|
|||||||
string | undefined
|
string | undefined
|
||||||
>({
|
>({
|
||||||
staleTime: STALE.MINUTES.FIVE,
|
staleTime: STALE.MINUTES.FIVE,
|
||||||
queryKey: RQKEY_PAGINATED(query),
|
queryKey: RQKEY_PAGINATED(query, limit),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const res = await agent.searchActors({
|
const res = await agent.searchActors({
|
||||||
q: query,
|
q: query,
|
||||||
limit: 25,
|
limit,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
|
|||||||
Reference in New Issue
Block a user