[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 {GestureResponderEvent, View} from 'react-native'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
@@ -23,15 +23,14 @@ import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
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 * as bsky from '#/types/bsky'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
type Props = {
|
||||
view: AppBskyFeedDefs.GeneratorView
|
||||
@@ -294,14 +293,19 @@ function SaveButtonInner({
|
||||
disabled={isPending}
|
||||
label={_(msg`Add this feed to your feeds`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="square"
|
||||
variant="solid"
|
||||
color={savedFeedConfig ? 'secondary' : 'primary'}
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
|
||||
{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>
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
type ModerationOpts,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
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 {sanitizeHandle} from '#/lib/strings/handles'
|
||||
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 {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
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 {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 {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function Default({
|
||||
profile,
|
||||
@@ -286,6 +291,7 @@ export type FollowButtonProps = {
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
colorInverted?: boolean
|
||||
onFollow?: () => void
|
||||
withIcon?: boolean
|
||||
} & Partial<ButtonProps>
|
||||
|
||||
export function FollowButton(props: FollowButtonProps) {
|
||||
@@ -301,6 +307,7 @@ export function FollowButtonInner({
|
||||
onPress: onPressProp,
|
||||
onFollow,
|
||||
colorInverted,
|
||||
withIcon = true,
|
||||
...rest
|
||||
}: FollowButtonProps) {
|
||||
const {_} = useLingui()
|
||||
@@ -386,7 +393,9 @@ export function FollowButtonInner({
|
||||
color="secondary"
|
||||
{...rest}
|
||||
onPress={onPressUnfollow}>
|
||||
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||
{withIcon && (
|
||||
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||
)}
|
||||
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -397,7 +406,9 @@ export function FollowButtonInner({
|
||||
color={colorInverted ? 'secondary_inverted' : 'primary'}
|
||||
{...rest}
|
||||
onPress={onPressFollow}>
|
||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||
{withIcon && (
|
||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||
)}
|
||||
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Animated, {
|
||||
LinearTransition,
|
||||
ZoomInEasyDown,
|
||||
} 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 {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -19,8 +19,8 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
||||
import {ListMethods} from '#/view/com/util/List'
|
||||
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {
|
||||
popularInterests,
|
||||
useInterestsDisplayNames,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
tokens,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
ViewStyleProp,
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -452,12 +452,14 @@ let Tabs = ({
|
||||
selectedInterest,
|
||||
hasSearchText,
|
||||
interestsDisplayNames,
|
||||
TabComponent = Tab,
|
||||
}: {
|
||||
onSelectTab: (tab: string) => void
|
||||
interests: string[]
|
||||
selectedInterest: string
|
||||
hasSearchText: boolean
|
||||
interestsDisplayNames: Record<string, string>
|
||||
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
|
||||
}): React.ReactNode => {
|
||||
const listRef = useRef<ScrollView>(null)
|
||||
const [scrollX, setScrollX] = useState(0)
|
||||
@@ -532,7 +534,7 @@ let Tabs = ({
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !hasSearchText
|
||||
return (
|
||||
<Tab
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
@@ -547,6 +549,7 @@ let Tabs = ({
|
||||
)
|
||||
}
|
||||
Tabs = memo(Tabs)
|
||||
export {Tabs}
|
||||
|
||||
let Tab = ({
|
||||
onSelectTab,
|
||||
@@ -822,7 +825,7 @@ function Empty({message}: {message: string}) {
|
||||
)
|
||||
}
|
||||
|
||||
function boostInterests(boosts?: string[]) {
|
||||
export function boostInterests(boosts?: string[]) {
|
||||
return (_a: string, _b: string) => {
|
||||
const indexA = boosts?.indexOf(_a) ?? -1
|
||||
const indexB = boosts?.indexOf(_b) ?? -1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StyleSheet, TextProps} from 'react-native'
|
||||
import type {PathProps, SvgProps} from 'react-native-svg'
|
||||
import {StyleSheet, type TextProps} from 'react-native'
|
||||
import {type PathProps, type SvgProps} from 'react-native-svg'
|
||||
import {Defs, LinearGradient, Stop} from 'react-native-svg'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
@@ -19,7 +19,7 @@ export const sizes = {
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xl': 32,
|
||||
}
|
||||
} as const
|
||||
|
||||
export function useCommonSVGProps(props: Props) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'debug_subscriptions'
|
||||
| 'explore_show_suggested_feeds'
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'remove_show_latest_button'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
|
||||
export type MetricEvents = {
|
||||
// App events
|
||||
init: {
|
||||
@@ -202,6 +204,7 @@ export type MetricEvents = {
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'PostOnboardingFindFollows'
|
||||
| 'ImmersiveVideo'
|
||||
| 'ExploreSuggestedAccounts'
|
||||
}
|
||||
'suggestedUser:follow': {
|
||||
logContext:
|
||||
@@ -239,6 +242,7 @@ export type MetricEvents = {
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'PostOnboardingFindFollows'
|
||||
| 'ImmersiveVideo'
|
||||
| 'ExploreSuggestedAccounts'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||
@@ -318,6 +322,22 @@ export type MetricEvents = {
|
||||
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:followDialog:open': {}
|
||||
|
||||
|
||||
+373
-352
@@ -1,135 +1,49 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyFeedDefs,
|
||||
moderateProfile,
|
||||
type ModerationDecision,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
|
||||
import {type AppBskyActorDefs, type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {type MetricEvents} from '#/logger/metrics'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
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 {
|
||||
FeedFeedLoadingPlaceholder,
|
||||
ProfileCardFeedLoadingPlaceholder,
|
||||
} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
|
||||
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
|
||||
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 * 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 {type 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'
|
||||
import * as ModuleHeader from './components/ModuleHeader'
|
||||
import {
|
||||
SuggestedAccountsTabBar,
|
||||
SuggestedProfileCard,
|
||||
} from './modules/ExploreSuggestedAccounts'
|
||||
|
||||
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_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
|
||||
}) {
|
||||
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
|
||||
const t = useTheme()
|
||||
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 (
|
||||
<View style={[]}>
|
||||
<View style={[a.pb_2xl]}>
|
||||
<Button
|
||||
label={_(msg`Load more`)}
|
||||
onPress={item.onLoadMore}
|
||||
@@ -140,93 +54,27 @@ function LoadMore({
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.gap_sm,
|
||||
(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
|
||||
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>
|
||||
)}
|
||||
{item.message}
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_1, a.align_end]}>
|
||||
{item.isLoadingMore && <Loader size="lg" />}
|
||||
</View>
|
||||
{item.isLoadingMore ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<ChevronDownIcon
|
||||
size="sm"
|
||||
style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
@@ -235,13 +83,31 @@ function LoadMore({
|
||||
}
|
||||
|
||||
type ExploreScreenItems =
|
||||
| {
|
||||
type: 'topBorder'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'header'
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
style?: ViewStyleProp['style']
|
||||
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'
|
||||
@@ -269,9 +135,9 @@ type ExploreScreenItems =
|
||||
| {
|
||||
type: 'loadMore'
|
||||
key: string
|
||||
message: string
|
||||
isLoadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
items: ExploreScreenItems[]
|
||||
}
|
||||
| {
|
||||
type: 'profilePlaceholder'
|
||||
@@ -288,19 +154,38 @@ type ExploreScreenItems =
|
||||
error: string
|
||||
}
|
||||
|
||||
export function Explore() {
|
||||
export function Explore({
|
||||
focusSearchInput,
|
||||
}: {
|
||||
focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {data: preferences, error: preferencesError} = usePreferencesQuery()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const gate = useGate()
|
||||
const guide = useProgressGuide('follow-10')
|
||||
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
|
||||
const {
|
||||
data: profiles,
|
||||
hasNextPage: hasNextProfilesPage,
|
||||
isLoading: isLoadingProfiles,
|
||||
isFetchingNextPage: isFetchingNextProfilesPage,
|
||||
error: profilesError,
|
||||
fetchNextPage: fetchNextProfilesPage,
|
||||
} = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10})
|
||||
data: suggestedProfiles,
|
||||
hasNextPage: hasNextSuggestedProfilesPage,
|
||||
isLoading: isLoadingSuggestedProfiles,
|
||||
isFetchingNextPage: isFetchingNextSuggestedProfilesPage,
|
||||
error: suggestedProfilesError,
|
||||
fetchNextPage: fetchNextSuggestedProfilesPage,
|
||||
} = 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 {
|
||||
data: feeds,
|
||||
hasNextPage: hasNextFeedsPage,
|
||||
@@ -310,6 +195,24 @@ export function Explore() {
|
||||
fetchNextPage: fetchNextFeedsPage,
|
||||
} = 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 onLoadMoreProfiles = useCallback(async () => {
|
||||
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
|
||||
@@ -327,8 +230,13 @@ export function Explore() {
|
||||
])
|
||||
|
||||
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
|
||||
const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false)
|
||||
const onLoadMoreFeeds = useCallback(async () => {
|
||||
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
|
||||
if (!hasPressedLoadMoreFeeds) {
|
||||
setHasPressedLoadMoreFeeds(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetchNextFeedsPage()
|
||||
} catch (err) {
|
||||
@@ -339,156 +247,198 @@ export function Explore() {
|
||||
hasNextFeedsPage,
|
||||
feedsError,
|
||||
fetchNextFeedsPage,
|
||||
hasPressedLoadMoreFeeds,
|
||||
])
|
||||
|
||||
const items = useMemo<ExploreScreenItems[]>(() => {
|
||||
const i: ExploreScreenItems[] = []
|
||||
|
||||
i.push({
|
||||
type: 'trendingTopics',
|
||||
key: `trending-topics`,
|
||||
})
|
||||
const addTopBorder = () => {
|
||||
i.push({
|
||||
type: 'topBorder',
|
||||
key: `top-border`,
|
||||
})
|
||||
}
|
||||
|
||||
// temp - disable trending videos
|
||||
// if (isNative) {
|
||||
// i.push({
|
||||
// type: 'trendingVideos',
|
||||
// key: `trending-videos`,
|
||||
// })
|
||||
// }
|
||||
const addTrendingTopicsModule = () => {
|
||||
i.push({
|
||||
type: 'trendingTopics',
|
||||
key: `trending-topics`,
|
||||
})
|
||||
|
||||
i.push({
|
||||
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,
|
||||
})
|
||||
// temp - disable trending videos
|
||||
// if (isNative) {
|
||||
// i.push({
|
||||
// type: 'trendingVideos',
|
||||
// key: `trending-videos`,
|
||||
// })
|
||||
// }
|
||||
}
|
||||
|
||||
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()
|
||||
const profileItems: ExploreScreenItems[] = []
|
||||
for (const page of profiles.pages) {
|
||||
for (const actor of page.actors) {
|
||||
if (!seen.has(actor.did)) {
|
||||
seen.add(actor.did)
|
||||
profileItems.push({
|
||||
type: 'profile',
|
||||
key: actor.did,
|
||||
profile: actor,
|
||||
recId: page.recId,
|
||||
const addSuggestedFollowsModule = () => {
|
||||
i.push({
|
||||
type: 'tabbedHeader',
|
||||
key: 'suggested-accounts-header',
|
||||
title: _(msg`Suggested Accounts`),
|
||||
icon: Person,
|
||||
searchButton: {
|
||||
label: _(msg`Search for more accounts`),
|
||||
metricsTag: 'suggestedAccounts',
|
||||
tab: 'user',
|
||||
},
|
||||
})
|
||||
|
||||
if (profiles && moderationOpts) {
|
||||
// Currently the responses contain duplicate items.
|
||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
||||
let seen = new Set()
|
||||
const profileItems: ExploreScreenItems[] = []
|
||||
for (const page of profiles.pages) {
|
||||
for (const actor of page.actors) {
|
||||
if (!seen.has(actor.did)) {
|
||||
seen.add(actor.did)
|
||||
profileItems.push({
|
||||
type: 'profile',
|
||||
key: actor.did,
|
||||
profile: actor,
|
||||
recId: page.recId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profileItems.length === 0) {
|
||||
// no items! remove the header
|
||||
i.pop()
|
||||
} else {
|
||||
i.push(...profileItems)
|
||||
if (hasNextProfilesPage) {
|
||||
i.push({
|
||||
type: 'loadMore',
|
||||
key: 'loadMoreProfiles',
|
||||
message: _(msg`Load more suggested accounts`),
|
||||
isLoadingMore: isLoadingMoreProfiles,
|
||||
onLoadMore: onLoadMoreProfiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNextProfilesPage) {
|
||||
// splice off 3 as previews if we have a next page
|
||||
const previews = profileItems.splice(-3)
|
||||
// push remainder
|
||||
i.push(...profileItems)
|
||||
i.push({
|
||||
type: 'loadMore',
|
||||
key: 'loadMoreProfiles',
|
||||
isLoadingMore: isLoadingMoreProfiles,
|
||||
onLoadMore: onLoadMoreProfiles,
|
||||
items: previews,
|
||||
})
|
||||
} else {
|
||||
i.push(...profileItems)
|
||||
}
|
||||
} 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'})
|
||||
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`Choose your own timeline! Feeds built by the community help you find content you love.`,
|
||||
),
|
||||
style: [a.pt_5xl],
|
||||
icon: ListSparkle,
|
||||
})
|
||||
const addSuggestedFeedsModule = () => {
|
||||
i.push({
|
||||
type: 'header',
|
||||
key: 'suggested-feeds-header',
|
||||
title: _(msg`Discover Feeds`),
|
||||
icon: ListSparkle,
|
||||
searchButton: {
|
||||
label: _(msg`Search for more feeds`),
|
||||
metricsTag: 'suggestedFeeds',
|
||||
tab: 'feed',
|
||||
},
|
||||
})
|
||||
|
||||
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()
|
||||
const feedItems: ExploreScreenItems[] = []
|
||||
for (const page of feeds.pages) {
|
||||
for (const feed of page.feeds) {
|
||||
if (!seen.has(feed.uri)) {
|
||||
seen.add(feed.uri)
|
||||
feedItems.push({
|
||||
type: 'feed',
|
||||
key: feed.uri,
|
||||
feed,
|
||||
})
|
||||
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()
|
||||
const feedItems: ExploreScreenItems[] = []
|
||||
for (const page of feeds.pages) {
|
||||
for (const feed of page.feeds) {
|
||||
if (!seen.has(feed.uri)) {
|
||||
seen.add(feed.uri)
|
||||
feedItems.push({
|
||||
type: 'feed',
|
||||
key: feed.uri,
|
||||
feed,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// feeds errors can occur during pagination, so feeds is truthy
|
||||
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 if (hasNextFeedsPage) {
|
||||
const preview = feedItems.splice(-3)
|
||||
i.push(...feedItems)
|
||||
i.push({
|
||||
type: 'loadMore',
|
||||
key: 'loadMoreFeeds',
|
||||
isLoadingMore: isLoadingMoreFeeds,
|
||||
onLoadMore: onLoadMoreFeeds,
|
||||
items: preview,
|
||||
})
|
||||
// feeds errors can occur during pagination, so feeds is truthy
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if (hasNextFeedsPage) {
|
||||
i.push({
|
||||
type: 'loadMore',
|
||||
key: 'loadMoreFeeds',
|
||||
message: _(msg`Load more suggested feeds`),
|
||||
isLoadingMore: isLoadingMoreFeeds,
|
||||
onLoadMore: onLoadMoreFeeds,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i.push(...feedItems)
|
||||
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'})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic module ordering
|
||||
|
||||
addTopBorder()
|
||||
|
||||
if (guide?.guide === 'follow-10' && !guide.isComplete) {
|
||||
addSuggestedFollowsModule()
|
||||
addTrendingTopicsModule()
|
||||
} 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'})
|
||||
}
|
||||
addTrendingTopicsModule()
|
||||
addSuggestedFollowsModule()
|
||||
}
|
||||
|
||||
if (gate('explore_show_suggested_feeds')) {
|
||||
addSuggestedFeedsModule()
|
||||
}
|
||||
|
||||
return i
|
||||
@@ -506,19 +456,61 @@ export function Explore() {
|
||||
preferencesError,
|
||||
hasNextProfilesPage,
|
||||
hasNextFeedsPage,
|
||||
guide,
|
||||
gate,
|
||||
moderationOpts,
|
||||
hasPressedLoadMoreFeeds,
|
||||
])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item, index}: {item: ExploreScreenItems; index: number}) => {
|
||||
switch (item.type) {
|
||||
case 'topBorder':
|
||||
return (
|
||||
<View style={[a.w_full, t.atoms.border_contrast_low, a.border_t]} />
|
||||
)
|
||||
case 'header': {
|
||||
return (
|
||||
<SuggestedItemsHeader
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
style={item.style}
|
||||
icon={item.icon}
|
||||
/>
|
||||
<ModuleHeader.Container>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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': {
|
||||
@@ -532,29 +524,12 @@ export function Explore() {
|
||||
}
|
||||
case 'profile': {
|
||||
return (
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
profile={item.profile}
|
||||
noBg
|
||||
noBorder
|
||||
showKnownFollowers
|
||||
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>
|
||||
<SuggestedProfileCard
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
recId={item.recId}
|
||||
position={index}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'feed': {
|
||||
@@ -571,7 +546,7 @@ export function Explore() {
|
||||
)
|
||||
}
|
||||
case 'loadMore': {
|
||||
return <LoadMore item={item} moderationOpts={moderationOpts} />
|
||||
return <LoadMore item={item} />
|
||||
}
|
||||
case 'profilePlaceholder': {
|
||||
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 (
|
||||
<List
|
||||
data={items}
|
||||
@@ -630,6 +644,13 @@ export function Explore() {
|
||||
contentContainerStyle={{paddingBottom: 100}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const viewabilityConfig: ViewabilityConfig = {
|
||||
itemVisiblePercentThreshold: 100,
|
||||
}
|
||||
|
||||
@@ -266,6 +266,10 @@ export function SearchScreenShell({
|
||||
}
|
||||
}, [setShowAutocomplete])
|
||||
|
||||
const focusSearchInput = useCallback(() => {
|
||||
textInput.current?.focus()
|
||||
}, [])
|
||||
|
||||
const showHeader = !gtMobile || navButton !== 'menu'
|
||||
|
||||
return (
|
||||
@@ -399,6 +403,7 @@ export function SearchScreenShell({
|
||||
query={query}
|
||||
queryWithParams={queryWithParams}
|
||||
headerHeight={headerHeight}
|
||||
focusSearchInput={focusSearchInput}
|
||||
/>
|
||||
</View>
|
||||
</Layout.Screen>
|
||||
@@ -409,10 +414,12 @@ let SearchScreenInner = ({
|
||||
query,
|
||||
queryWithParams,
|
||||
headerHeight,
|
||||
focusSearchInput,
|
||||
}: {
|
||||
query: string
|
||||
queryWithParams: string
|
||||
headerHeight: number
|
||||
focusSearchInput: () => void
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
@@ -438,7 +445,7 @@ let SearchScreenInner = ({
|
||||
onPageSelected={onPageSelected}
|
||||
/>
|
||||
) : hasSession ? (
|
||||
<Explore />
|
||||
<Explore focusSearchInput={focusSearchInput} />
|
||||
) : (
|
||||
<Layout.Center>
|
||||
<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 {
|
||||
InfiniteData,
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyActorSearchActors,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
type InfiniteData,
|
||||
keepPreviousData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
@@ -15,7 +18,11 @@ const RQKEY_ROOT = 'actor-search'
|
||||
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
||||
|
||||
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({
|
||||
query,
|
||||
@@ -42,10 +49,12 @@ export function useActorSearchPaginated({
|
||||
query,
|
||||
enabled,
|
||||
maintainData,
|
||||
limit = 25,
|
||||
}: {
|
||||
query: string
|
||||
enabled?: boolean
|
||||
maintainData?: boolean
|
||||
limit?: number
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
return useInfiniteQuery<
|
||||
@@ -56,11 +65,11 @@ export function useActorSearchPaginated({
|
||||
string | undefined
|
||||
>({
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
queryKey: RQKEY_PAGINATED(query),
|
||||
queryKey: RQKEY_PAGINATED(query, limit),
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.searchActors({
|
||||
q: query,
|
||||
limit: 25,
|
||||
limit,
|
||||
cursor: pageParam,
|
||||
})
|
||||
return res.data
|
||||
|
||||
Reference in New Issue
Block a user