[APP-1357] profile header follow recommendations (#8784)
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {ScrollView, View} from 'react-native'
|
||||||
import {ScrollView} from 'react-native-gesture-handler'
|
|
||||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
import {type AppBskyFeedDefs, AtUri} 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'
|
||||||
@@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native'
|
|||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type ViewStyleProp,
|
type ViewStyleProp,
|
||||||
web,
|
web,
|
||||||
} from '#/alf'
|
} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import * as FeedCard from '#/components/FeedCard'
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
@@ -46,11 +46,13 @@ function CardOuter({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
a.flex_1,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.p_md,
|
a.p_md,
|
||||||
a.rounded_lg,
|
a.rounded_lg,
|
||||||
a.border,
|
a.border,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
|
t.atoms.shadow_sm,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
!gtMobile && {
|
!gtMobile && {
|
||||||
width: MOBILE_CARD_WIDTH,
|
width: MOBILE_CARD_WIDTH,
|
||||||
@@ -63,11 +65,8 @@ function CardOuter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollowPlaceholder() {
|
export function SuggestedFollowPlaceholder() {
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardOuter
|
<CardOuter>
|
||||||
style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
|
|
||||||
<ProfileCard.Outer>
|
<ProfileCard.Outer>
|
||||||
<View
|
<View
|
||||||
style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
|
style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
|
||||||
@@ -78,24 +77,15 @@ export function SuggestedFollowPlaceholder() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<ProfileCard.FollowButtonPlaceholder />
|
||||||
label=""
|
|
||||||
size="small"
|
|
||||||
variant="solid"
|
|
||||||
color="secondary"
|
|
||||||
disabled
|
|
||||||
style={[a.w_full, a.rounded_sm]}>
|
|
||||||
<ButtonText>Follow</ButtonText>
|
|
||||||
</Button>
|
|
||||||
</ProfileCard.Outer>
|
</ProfileCard.Outer>
|
||||||
</CardOuter>
|
</CardOuter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFeedsCardPlaceholder() {
|
export function SuggestedFeedsCardPlaceholder() {
|
||||||
const t = useTheme()
|
|
||||||
return (
|
return (
|
||||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
<CardOuter style={[a.gap_sm]}>
|
||||||
<FeedCard.Header>
|
<FeedCard.Header>
|
||||||
<FeedCard.AvatarPlaceholder />
|
<FeedCard.AvatarPlaceholder />
|
||||||
<FeedCard.TitleAndBylinePlaceholder creator />
|
<FeedCard.TitleAndBylinePlaceholder creator />
|
||||||
@@ -253,129 +243,133 @@ export function ProfileGrid({
|
|||||||
profiles: bsky.profile.AnyProfileView[]
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
recId?: number
|
recId?: number
|
||||||
error: Error | null
|
error: Error | null
|
||||||
viewContext: 'profile' | 'feed'
|
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
|
||||||
const maxLength = gtMobile ? 3 : 6
|
|
||||||
|
|
||||||
const content = isLoading ? (
|
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||||
Array(maxLength)
|
const isProfileHeaderContext = viewContext === 'profileHeader'
|
||||||
.fill(0)
|
const isFeedContext = viewContext === 'feed'
|
||||||
.map((_, i) => (
|
|
||||||
<View
|
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
|
||||||
key={i}
|
const minLength = gtMobile ? 3 : 4
|
||||||
style={[
|
|
||||||
gtMobile &&
|
const content = isLoading
|
||||||
web([
|
? Array(maxLength)
|
||||||
a.flex_0,
|
.fill(0)
|
||||||
a.flex_grow,
|
.map((_, i) => (
|
||||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
<View
|
||||||
]),
|
key={i}
|
||||||
]}>
|
style={[
|
||||||
<SuggestedFollowPlaceholder />
|
a.flex_1,
|
||||||
</View>
|
gtMobile &&
|
||||||
))
|
web([
|
||||||
) : error || !profiles.length ? null : (
|
a.flex_0,
|
||||||
<>
|
a.flex_grow,
|
||||||
{profiles.slice(0, maxLength).map((profile, index) => (
|
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||||
<ProfileCard.Link
|
]),
|
||||||
key={profile.did}
|
]}>
|
||||||
profile={profile}
|
<SuggestedFollowPlaceholder />
|
||||||
onPress={() => {
|
</View>
|
||||||
logEvent('suggestedUser:press', {
|
))
|
||||||
logContext:
|
: error || !profiles.length
|
||||||
viewContext === 'feed'
|
? null
|
||||||
|
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||||
|
<ProfileCard.Link
|
||||||
|
key={profile.did}
|
||||||
|
profile={profile}
|
||||||
|
onPress={() => {
|
||||||
|
logEvent('suggestedUser:press', {
|
||||||
|
logContext: isFeedContext
|
||||||
? 'InterstitialDiscover'
|
? 'InterstitialDiscover'
|
||||||
: 'InterstitialProfile',
|
: 'InterstitialProfile',
|
||||||
recId,
|
recId,
|
||||||
position: index,
|
position: index,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
gtMobile &&
|
gtMobile &&
|
||||||
web([
|
web([
|
||||||
a.flex_0,
|
a.flex_0,
|
||||||
a.flex_grow,
|
a.flex_grow,
|
||||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||||
]),
|
]),
|
||||||
]}>
|
]}>
|
||||||
{({hovered, pressed}) => (
|
{({hovered, pressed}) => (
|
||||||
<CardOuter
|
<CardOuter
|
||||||
style={[
|
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||||
a.flex_1,
|
<ProfileCard.Outer>
|
||||||
t.atoms.shadow_sm,
|
<View
|
||||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
style={[
|
||||||
]}>
|
a.flex_col,
|
||||||
<ProfileCard.Outer>
|
a.align_center,
|
||||||
<View
|
a.gap_sm,
|
||||||
style={[
|
a.pb_sm,
|
||||||
a.flex_col,
|
a.mb_auto,
|
||||||
a.align_center,
|
]}>
|
||||||
a.gap_sm,
|
<ProfileCard.Avatar
|
||||||
a.pb_sm,
|
|
||||||
a.mb_auto,
|
|
||||||
]}>
|
|
||||||
<ProfileCard.Avatar
|
|
||||||
profile={profile}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
size={88}
|
|
||||||
/>
|
|
||||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
|
||||||
<ProfileCard.Name
|
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
|
disabledPreview
|
||||||
|
size={88}
|
||||||
/>
|
/>
|
||||||
<ProfileCard.Description
|
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||||
profile={profile}
|
<ProfileCard.Name
|
||||||
numberOfLines={2}
|
profile={profile}
|
||||||
style={[
|
moderationOpts={moderationOpts}
|
||||||
t.atoms.text_contrast_medium,
|
/>
|
||||||
a.text_center,
|
<ProfileCard.Description
|
||||||
a.text_xs,
|
profile={profile}
|
||||||
]}
|
numberOfLines={2}
|
||||||
/>
|
style={[
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.text_center,
|
||||||
|
a.text_xs,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<ProfileCard.FollowButton
|
<ProfileCard.FollowButton
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
logContext="FeedInterstitial"
|
logContext="FeedInterstitial"
|
||||||
withIcon={false}
|
withIcon={false}
|
||||||
style={[a.rounded_sm]}
|
style={[a.rounded_sm]}
|
||||||
onFollow={() => {
|
onFollow={() => {
|
||||||
logEvent('suggestedUser:follow', {
|
logEvent('suggestedUser:follow', {
|
||||||
logContext:
|
logContext: isFeedContext
|
||||||
viewContext === 'feed'
|
|
||||||
? 'InterstitialDiscover'
|
? 'InterstitialDiscover'
|
||||||
: 'InterstitialProfile',
|
: 'InterstitialProfile',
|
||||||
location: 'Card',
|
location: 'Card',
|
||||||
recId,
|
recId,
|
||||||
position: index,
|
position: index,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ProfileCard.Outer>
|
</ProfileCard.Outer>
|
||||||
</CardOuter>
|
</CardOuter>
|
||||||
)}
|
)}
|
||||||
</ProfileCard.Link>
|
</ProfileCard.Link>
|
||||||
))}
|
))
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (error || (!isLoading && profiles.length < 4)) {
|
if (error || (!isLoading && profiles.length < minLength)) {
|
||||||
logger.debug(`Not enough profiles to show suggested follows`)
|
logger.debug(`Not enough profiles to show suggested follows`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
style={[
|
||||||
|
!isProfileHeaderContext && a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}
|
||||||
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
@@ -383,19 +377,22 @@ export function ProfileGrid({
|
|||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.justify_between,
|
a.justify_between,
|
||||||
]}>
|
]}
|
||||||
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
|
<Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
|
||||||
{viewContext === 'profile' ? (
|
{isFeedContext ? (
|
||||||
<Trans>Similar accounts</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Suggested for you</Trans>
|
<Trans>Suggested for you</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Similar accounts</Trans>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<InlineLinkText
|
{!isProfileHeaderContext && (
|
||||||
label={_(msg`See more suggested profiles on the Explore page`)}
|
<InlineLinkText
|
||||||
to="/search">
|
label={_(msg`See more suggested profiles on the Explore page`)}
|
||||||
<Trans>See more</Trans>
|
to="/search">
|
||||||
</InlineLinkText>
|
<Trans>See more</Trans>
|
||||||
|
</InlineLinkText>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{gtMobile ? (
|
{gtMobile ? (
|
||||||
@@ -406,19 +403,16 @@ export function ProfileGrid({
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<BlockDrawerGesture>
|
<BlockDrawerGesture>
|
||||||
<View>
|
<ScrollView
|
||||||
<ScrollView
|
horizontal
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||||
decelerationRate="fast">
|
decelerationRate="fast">
|
||||||
<View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}>
|
{content}
|
||||||
{content}
|
|
||||||
|
|
||||||
<SeeMoreSuggestedProfilesCard />
|
{!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />}
|
||||||
</View>
|
</ScrollView>
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</BlockDrawerGesture>
|
</BlockDrawerGesture>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -427,7 +421,6 @@ export function ProfileGrid({
|
|||||||
|
|
||||||
function SeeMoreSuggestedProfilesCard() {
|
function SeeMoreSuggestedProfilesCard() {
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -437,7 +430,7 @@ function SeeMoreSuggestedProfilesCard() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.navigate('SearchTab')
|
navigation.navigate('SearchTab')
|
||||||
}}>
|
}}>
|
||||||
<CardOuter style={[a.flex_1, t.atoms.shadow_sm]}>
|
<CardOuter>
|
||||||
<View style={[a.flex_1, a.justify_center]}>
|
<View style={[a.flex_1, a.justify_center]}>
|
||||||
<View style={[a.flex_col, a.align_center, a.gap_md]}>
|
<View style={[a.flex_col, a.align_center, a.gap_md]}>
|
||||||
<Text style={[a.leading_snug, a.text_center]}>
|
<Text style={[a.leading_snug, a.text_center]}>
|
||||||
@@ -491,10 +484,7 @@ export function SuggestedFeeds() {
|
|||||||
}}>
|
}}>
|
||||||
{({hovered, pressed}) => (
|
{({hovered, pressed}) => (
|
||||||
<CardOuter
|
<CardOuter
|
||||||
style={[
|
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||||
a.flex_1,
|
|
||||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
|
||||||
]}>
|
|
||||||
<FeedCard.Outer>
|
<FeedCard.Outer>
|
||||||
<FeedCard.Header>
|
<FeedCard.Header>
|
||||||
<FeedCard.Avatar src={feed.avatar} />
|
<FeedCard.Avatar src={feed.avatar} />
|
||||||
@@ -568,7 +558,7 @@ export function SuggestedFeeds() {
|
|||||||
navigation.navigate('SearchTab')
|
navigation.navigate('SearchTab')
|
||||||
}}
|
}}
|
||||||
style={[a.flex_col]}>
|
style={[a.flex_col]}>
|
||||||
<CardOuter style={[a.flex_1]}>
|
<CardOuter>
|
||||||
<View style={[a.flex_1, a.justify_center]}>
|
<View style={[a.flex_1, a.justify_center]}>
|
||||||
<View style={[a.flex_row, a.px_lg]}>
|
<View style={[a.flex_row, a.px_lg]}>
|
||||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||||
|
|||||||
@@ -561,6 +561,24 @@ export function FollowButtonInner({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FollowButtonPlaceholder({style}: ViewStyleProp) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.w_full,
|
||||||
|
{
|
||||||
|
height: 33,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Labels({
|
export function Labels({
|
||||||
profile,
|
profile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type StyleProp,
|
||||||
|
View,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
FadeInUp,
|
||||||
|
FadeOutUp,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import {isIOS, isWeb} from '#/platform/detection'
|
||||||
|
|
||||||
|
type AccordionAnimationProps = React.PropsWithChildren<{
|
||||||
|
isExpanded: boolean
|
||||||
|
duration?: number
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}>
|
||||||
|
|
||||||
|
function WebAccordion({
|
||||||
|
isExpanded,
|
||||||
|
duration = 300,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: AccordionAnimationProps) {
|
||||||
|
const heightValue = useSharedValue(0)
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
const targetHeight = isExpanded ? heightValue.get() : 0
|
||||||
|
return {
|
||||||
|
height: withTiming(targetHeight, {
|
||||||
|
duration,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
}),
|
||||||
|
overflow: 'hidden',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => {
|
||||||
|
if (heightValue.get() === 0) {
|
||||||
|
heightValue.set(e.nativeEvent.layout.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[animatedStyle, style]}>
|
||||||
|
<View onLayout={onLayout}>{children}</View>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileAccordion({
|
||||||
|
isExpanded,
|
||||||
|
duration = 200,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: AccordionAnimationProps) {
|
||||||
|
if (!isExpanded) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={style}
|
||||||
|
entering={FadeInUp.duration(duration)}
|
||||||
|
exiting={FadeOutUp.duration(duration / 2)}
|
||||||
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccordionAnimation(props: AccordionAnimationProps) {
|
||||||
|
return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} />
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export type Gate =
|
|||||||
| 'handle_suggestions'
|
| 'handle_suggestions'
|
||||||
| 'old_postonboarding'
|
| 'old_postonboarding'
|
||||||
| 'onboarding_add_video_feed'
|
| 'onboarding_add_video_feed'
|
||||||
|
| 'post_follow_profile_suggested_accounts'
|
||||||
| 'post_threads_v2_unspecced'
|
| 'post_threads_v2_unspecced'
|
||||||
| 'remove_show_latest_button'
|
| 'remove_show_latest_button'
|
||||||
| 'test_gate_1'
|
| 'test_gate_1'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {memo, useMemo} from 'react'
|
import {memo, useCallback, useMemo, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
type AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
@@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog'
|
|||||||
import {ProfileHeaderHandle} from './Handle'
|
import {ProfileHeaderHandle} from './Handle'
|
||||||
import {ProfileHeaderMetrics} from './Metrics'
|
import {ProfileHeaderMetrics} from './Metrics'
|
||||||
import {ProfileHeaderShell} from './Shell'
|
import {ProfileHeaderShell} from './Shell'
|
||||||
|
import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
@@ -73,6 +74,7 @@ let ProfileHeaderStandard = ({
|
|||||||
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||||
const unblockPromptControl = Prompt.usePromptControl()
|
const unblockPromptControl = Prompt.usePromptControl()
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
|
const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
|
||||||
const isBlockedUser =
|
const isBlockedUser =
|
||||||
profile.viewer?.blocking ||
|
profile.viewer?.blocking ||
|
||||||
profile.viewer?.blockedBy ||
|
profile.viewer?.blockedBy ||
|
||||||
@@ -81,6 +83,7 @@ let ProfileHeaderStandard = ({
|
|||||||
const editProfileControl = useDialogControl()
|
const editProfileControl = useDialogControl()
|
||||||
|
|
||||||
const onPressFollow = () => {
|
const onPressFollow = () => {
|
||||||
|
setShowSuggestedFollows(true)
|
||||||
requireAuth(async () => {
|
requireAuth(async () => {
|
||||||
try {
|
try {
|
||||||
await queueFollow()
|
await queueFollow()
|
||||||
@@ -102,6 +105,7 @@ let ProfileHeaderStandard = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPressUnfollow = () => {
|
const onPressUnfollow = () => {
|
||||||
|
setShowSuggestedFollows(false)
|
||||||
requireAuth(async () => {
|
requireAuth(async () => {
|
||||||
try {
|
try {
|
||||||
await queueUnfollow()
|
await queueUnfollow()
|
||||||
@@ -122,7 +126,7 @@ let ProfileHeaderStandard = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unblockAccount = React.useCallback(async () => {
|
const unblockAccount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await queueUnblock()
|
await queueUnblock()
|
||||||
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
|
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
|
||||||
@@ -155,174 +159,185 @@ let ProfileHeaderStandard = ({
|
|||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProfileHeaderShell
|
<>
|
||||||
profile={profile}
|
<ProfileHeaderShell
|
||||||
moderation={moderation}
|
profile={profile}
|
||||||
hideBackButton={hideBackButton}
|
moderation={moderation}
|
||||||
isPlaceholderProfile={isPlaceholderProfile}>
|
hideBackButton={hideBackButton}
|
||||||
<View
|
isPlaceholderProfile={isPlaceholderProfile}>
|
||||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
|
||||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||||
{paddingLeft: 90},
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_end,
|
|
||||||
a.gap_xs,
|
|
||||||
a.pb_sm,
|
|
||||||
a.flex_wrap,
|
|
||||||
]}
|
|
||||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
{isMe ? (
|
<View
|
||||||
<>
|
style={[
|
||||||
<Button
|
{paddingLeft: 90},
|
||||||
testID="profileHeaderEditProfileButton"
|
a.flex_row,
|
||||||
size="small"
|
a.align_center,
|
||||||
color="secondary"
|
a.justify_end,
|
||||||
variant="solid"
|
a.gap_xs,
|
||||||
onPress={editProfileControl.open}
|
a.pb_sm,
|
||||||
label={_(msg`Edit profile`)}
|
a.flex_wrap,
|
||||||
style={[a.rounded_full]}>
|
]}
|
||||||
<ButtonText>
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
<Trans>Edit Profile</Trans>
|
{isMe ? (
|
||||||
</ButtonText>
|
<>
|
||||||
</Button>
|
<Button
|
||||||
<EditProfileDialog
|
testID="profileHeaderEditProfileButton"
|
||||||
profile={profile}
|
size="small"
|
||||||
control={editProfileControl}
|
color="secondary"
|
||||||
/>
|
variant="solid"
|
||||||
</>
|
onPress={editProfileControl.open}
|
||||||
) : profile.viewer?.blocking ? (
|
label={_(msg`Edit profile`)}
|
||||||
profile.viewer?.blockingByList ? null : (
|
style={[a.rounded_full]}>
|
||||||
<Button
|
<ButtonText>
|
||||||
testID="unblockBtn"
|
<Trans>Edit Profile</Trans>
|
||||||
size="small"
|
</ButtonText>
|
||||||
color="secondary"
|
</Button>
|
||||||
variant="solid"
|
<EditProfileDialog
|
||||||
label={_(msg`Unblock`)}
|
|
||||||
disabled={!hasSession}
|
|
||||||
onPress={() => unblockPromptControl.open()}
|
|
||||||
style={[a.rounded_full]}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans context="action">Unblock</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
) : !profile.viewer?.blockedBy ? (
|
|
||||||
<>
|
|
||||||
{hasSession && subscriptionsAllowed && (
|
|
||||||
<SubscribeProfileButton
|
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
control={editProfileControl}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
{hasSession && <MessageProfileButton profile={profile} />}
|
) : profile.viewer?.blocking ? (
|
||||||
|
profile.viewer?.blockingByList ? null : (
|
||||||
<Button
|
<Button
|
||||||
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
testID="unblockBtn"
|
||||||
size="small"
|
size="small"
|
||||||
color={profile.viewer?.following ? 'secondary' : 'primary'}
|
color="secondary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
label={
|
label={_(msg`Unblock`)}
|
||||||
profile.viewer?.following
|
disabled={!hasSession}
|
||||||
? _(msg`Unfollow ${profile.handle}`)
|
onPress={() => unblockPromptControl.open()}
|
||||||
: _(msg`Follow ${profile.handle}`)
|
style={[a.rounded_full]}>
|
||||||
}
|
<ButtonText>
|
||||||
onPress={
|
<Trans context="action">Unblock</Trans>
|
||||||
profile.viewer?.following ? onPressUnfollow : onPressFollow
|
</ButtonText>
|
||||||
}
|
</Button>
|
||||||
style={[a.rounded_full]}>
|
)
|
||||||
{!profile.viewer?.following && (
|
) : !profile.viewer?.blockedBy ? (
|
||||||
<ButtonIcon position="left" icon={Plus} />
|
<>
|
||||||
)}
|
{hasSession && subscriptionsAllowed && (
|
||||||
<ButtonText>
|
<SubscribeProfileButton
|
||||||
{profile.viewer?.following ? (
|
|
||||||
<Trans>Following</Trans>
|
|
||||||
) : profile.viewer?.followedBy ? (
|
|
||||||
<Trans>Follow Back</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Follow</Trans>
|
|
||||||
)}
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<ProfileMenu profile={profile} />
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
|
||||||
<Text
|
|
||||||
emoji
|
|
||||||
testID="profileHeaderDisplayName"
|
|
||||||
style={[
|
|
||||||
t.atoms.text,
|
|
||||||
gtMobile ? a.text_4xl : a.text_3xl,
|
|
||||||
a.self_start,
|
|
||||||
a.font_heavy,
|
|
||||||
a.leading_tight,
|
|
||||||
]}>
|
|
||||||
{sanitizeDisplayName(
|
|
||||||
profile.displayName || sanitizeHandle(profile.handle),
|
|
||||||
moderation.ui('displayName'),
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.pl_xs,
|
|
||||||
{
|
|
||||||
marginTop: platform({ios: 2}),
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<VerificationCheckButton profile={profile} size="lg" />
|
|
||||||
</View>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<ProfileHeaderHandle profile={profile} />
|
|
||||||
</View>
|
|
||||||
{!isPlaceholderProfile && !isBlockedUser && (
|
|
||||||
<View style={a.gap_md}>
|
|
||||||
<ProfileHeaderMetrics profile={profile} />
|
|
||||||
{descriptionRT && !moderation.ui('profileView').blur ? (
|
|
||||||
<View pointerEvents="auto">
|
|
||||||
<RichText
|
|
||||||
testID="profileHeaderDescription"
|
|
||||||
style={[a.text_md]}
|
|
||||||
numberOfLines={15}
|
|
||||||
value={descriptionRT}
|
|
||||||
enableTags
|
|
||||||
authorHandle={profile.handle}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{!isMe &&
|
|
||||||
!isBlockedUser &&
|
|
||||||
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<KnownFollowers
|
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
/>
|
/>
|
||||||
</View>
|
)}
|
||||||
)}
|
{hasSession && <MessageProfileButton profile={profile} />}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
testID={
|
||||||
|
profile.viewer?.following ? 'unfollowBtn' : 'followBtn'
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
color={profile.viewer?.following ? 'secondary' : 'primary'}
|
||||||
|
variant="solid"
|
||||||
|
label={
|
||||||
|
profile.viewer?.following
|
||||||
|
? _(msg`Unfollow ${profile.handle}`)
|
||||||
|
: _(msg`Follow ${profile.handle}`)
|
||||||
|
}
|
||||||
|
onPress={
|
||||||
|
profile.viewer?.following ? onPressUnfollow : onPressFollow
|
||||||
|
}
|
||||||
|
style={[a.rounded_full]}>
|
||||||
|
{!profile.viewer?.following && (
|
||||||
|
<ButtonIcon position="left" icon={Plus} />
|
||||||
|
)}
|
||||||
|
<ButtonText>
|
||||||
|
{profile.viewer?.following ? (
|
||||||
|
<Trans>Following</Trans>
|
||||||
|
) : profile.viewer?.followedBy ? (
|
||||||
|
<Trans>Follow Back</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Follow</Trans>
|
||||||
|
)}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<ProfileMenu profile={profile} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
<View
|
||||||
</View>
|
style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
|
||||||
<Prompt.Basic
|
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||||
control={unblockPromptControl}
|
<Text
|
||||||
title={_(msg`Unblock Account?`)}
|
emoji
|
||||||
description={_(
|
testID="profileHeaderDisplayName"
|
||||||
msg`The account will be able to interact with you after unblocking.`,
|
style={[
|
||||||
)}
|
t.atoms.text,
|
||||||
onConfirm={unblockAccount}
|
gtMobile ? a.text_4xl : a.text_3xl,
|
||||||
confirmButtonCta={
|
a.self_start,
|
||||||
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
|
a.font_heavy,
|
||||||
}
|
a.leading_tight,
|
||||||
confirmButtonColor="negative"
|
]}>
|
||||||
|
{sanitizeDisplayName(
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
|
moderation.ui('displayName'),
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.pl_xs,
|
||||||
|
{
|
||||||
|
marginTop: platform({ios: 2}),
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<VerificationCheckButton profile={profile} size="lg" />
|
||||||
|
</View>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ProfileHeaderHandle profile={profile} />
|
||||||
|
</View>
|
||||||
|
{!isPlaceholderProfile && !isBlockedUser && (
|
||||||
|
<View style={a.gap_md}>
|
||||||
|
<ProfileHeaderMetrics profile={profile} />
|
||||||
|
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||||
|
<View pointerEvents="auto">
|
||||||
|
<RichText
|
||||||
|
testID="profileHeaderDescription"
|
||||||
|
style={[a.text_md]}
|
||||||
|
numberOfLines={15}
|
||||||
|
value={descriptionRT}
|
||||||
|
enableTags
|
||||||
|
authorHandle={profile.handle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{!isMe &&
|
||||||
|
!isBlockedUser &&
|
||||||
|
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<KnownFollowers
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Prompt.Basic
|
||||||
|
control={unblockPromptControl}
|
||||||
|
title={_(msg`Unblock Account?`)}
|
||||||
|
description={_(
|
||||||
|
msg`The account will be able to interact with you after unblocking.`,
|
||||||
|
)}
|
||||||
|
onConfirm={unblockAccount}
|
||||||
|
confirmButtonCta={
|
||||||
|
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
|
||||||
|
}
|
||||||
|
confirmButtonColor="negative"
|
||||||
|
/>
|
||||||
|
</ProfileHeaderShell>
|
||||||
|
|
||||||
|
<AnimatedProfileHeaderSuggestedFollows
|
||||||
|
isExpanded={showSuggestedFollows}
|
||||||
|
actorDid={profile.did}
|
||||||
/>
|
/>
|
||||||
</ProfileHeaderShell>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ProfileHeaderStandard = memo(ProfileHeaderStandard)
|
ProfileHeaderStandard = memo(ProfileHeaderStandard)
|
||||||
export {ProfileHeaderStandard}
|
export {ProfileHeaderStandard}
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ let ProfileHeaderShell = ({
|
|||||||
|
|
||||||
{!isPlaceholderProfile && (
|
{!isPlaceholderProfile && (
|
||||||
<View
|
<View
|
||||||
style={[a.px_lg, a.py_xs]}
|
style={[a.px_lg, a.pt_xs, a.pb_sm]}
|
||||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
<LabelsOnMe type="account" labels={profile.labels} />
|
<LabelsOnMe type="account" labels={profile.labels} />
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {isAndroid} from '#/platform/detection'
|
||||||
|
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||||
|
import {ProfileGrid} from '#/components/FeedInterstitials'
|
||||||
|
|
||||||
|
export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
|
||||||
|
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
||||||
|
did: actorDid,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileGrid
|
||||||
|
isSuggestionsLoading={isLoading}
|
||||||
|
profiles={data?.suggestions ?? []}
|
||||||
|
recId={data?.recId}
|
||||||
|
error={error}
|
||||||
|
viewContext="profileHeader"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedProfileHeaderSuggestedFollows({
|
||||||
|
isExpanded,
|
||||||
|
actorDid,
|
||||||
|
}: {
|
||||||
|
isExpanded: boolean
|
||||||
|
actorDid: string
|
||||||
|
}) {
|
||||||
|
const gate = useGate()
|
||||||
|
if (!gate('post_follow_profile_suggested_accounts')) return null
|
||||||
|
|
||||||
|
/* NOTE (caidanw):
|
||||||
|
* Android does not work well with this feature yet.
|
||||||
|
* This issue stems from Android not allowing dragging on clickable elements in the profile header.
|
||||||
|
* Blocking the ability to scroll on Android is too much of a trade-off for now.
|
||||||
|
**/
|
||||||
|
if (isAndroid) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionAnimation isExpanded={isExpanded}>
|
||||||
|
<ProfileHeaderSuggestedFollows actorDid={actorDid} />
|
||||||
|
</AccordionAnimation>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
AppBskyActorGetSuggestions,
|
type AppBskyActorGetSuggestions,
|
||||||
AppBskyGraphGetSuggestedFollowsByActor,
|
type AppBskyGraphGetSuggestedFollowsByActor,
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
InfiniteData,
|
type InfiniteData,
|
||||||
QueryClient,
|
type QueryClient,
|
||||||
QueryKey,
|
type QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
@@ -106,12 +106,15 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
|||||||
export function useSuggestedFollowsByActorQuery({
|
export function useSuggestedFollowsByActorQuery({
|
||||||
did,
|
did,
|
||||||
enabled,
|
enabled,
|
||||||
|
staleTime = STALE.MINUTES.FIVE,
|
||||||
}: {
|
}: {
|
||||||
did: string
|
did: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
staleTime?: number
|
||||||
}) {
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
staleTime,
|
||||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||||
|
|||||||
Reference in New Issue
Block a user