[APP-1357] profile header follow recommendations (#8784)
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
@@ -46,11 +46,13 @@ function CardOuter({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.p_md,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.shadow_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
!gtMobile && {
|
||||
width: MOBILE_CARD_WIDTH,
|
||||
@@ -63,11 +65,8 @@ function CardOuter({
|
||||
}
|
||||
|
||||
export function SuggestedFollowPlaceholder() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<CardOuter
|
||||
style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
|
||||
<CardOuter>
|
||||
<ProfileCard.Outer>
|
||||
<View
|
||||
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>
|
||||
|
||||
<Button
|
||||
label=""
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled
|
||||
style={[a.w_full, a.rounded_sm]}>
|
||||
<ButtonText>Follow</ButtonText>
|
||||
</Button>
|
||||
<ProfileCard.FollowButtonPlaceholder />
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFeedsCardPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||
<CardOuter style={[a.gap_sm]}>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.AvatarPlaceholder />
|
||||
<FeedCard.TitleAndBylinePlaceholder creator />
|
||||
@@ -253,129 +243,133 @@ export function ProfileGrid({
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
recId?: number
|
||||
error: Error | null
|
||||
viewContext: 'profile' | 'feed'
|
||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const maxLength = gtMobile ? 3 : 6
|
||||
|
||||
const content = isLoading ? (
|
||||
Array(maxLength)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
gtMobile &&
|
||||
web([
|
||||
a.flex_0,
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
]}>
|
||||
<SuggestedFollowPlaceholder />
|
||||
</View>
|
||||
))
|
||||
) : error || !profiles.length ? null : (
|
||||
<>
|
||||
{profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<ProfileCard.Link
|
||||
key={profile.did}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext:
|
||||
viewContext === 'feed'
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const isProfileHeaderContext = viewContext === 'profileHeader'
|
||||
const isFeedContext = viewContext === 'feed'
|
||||
|
||||
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
|
||||
const minLength = gtMobile ? 3 : 4
|
||||
|
||||
const content = isLoading
|
||||
? Array(maxLength)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
web([
|
||||
a.flex_0,
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
]}>
|
||||
<SuggestedFollowPlaceholder />
|
||||
</View>
|
||||
))
|
||||
: error || !profiles.length
|
||||
? null
|
||||
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<ProfileCard.Link
|
||||
key={profile.did}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId,
|
||||
position: index,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
web([
|
||||
a.flex_0,
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.shadow_sm,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
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
|
||||
recId,
|
||||
position: index,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
web([
|
||||
a.flex_0,
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||
<ProfileCard.Outer>
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pb_sm,
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
a.text_xs,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
a.text_xs,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext:
|
||||
viewContext === 'feed'
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId,
|
||||
position: index,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
location: 'Card',
|
||||
recId,
|
||||
position: index,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
))
|
||||
|
||||
if (error || (!isLoading && profiles.length < 4)) {
|
||||
if (error || (!isLoading && profiles.length < minLength)) {
|
||||
logger.debug(`Not enough profiles to show suggested follows`)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
style={[
|
||||
a.px_lg,
|
||||
@@ -383,19 +377,22 @@ export function ProfileGrid({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}>
|
||||
]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
|
||||
{viewContext === 'profile' ? (
|
||||
<Trans>Similar accounts</Trans>
|
||||
) : (
|
||||
{isFeedContext ? (
|
||||
<Trans>Suggested for you</Trans>
|
||||
) : (
|
||||
<Trans>Similar accounts</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<InlineLinkText
|
||||
label={_(msg`See more suggested profiles on the Explore page`)}
|
||||
to="/search">
|
||||
<Trans>See more</Trans>
|
||||
</InlineLinkText>
|
||||
{!isProfileHeaderContext && (
|
||||
<InlineLinkText
|
||||
label={_(msg`See more suggested profiles on the Explore page`)}
|
||||
to="/search">
|
||||
<Trans>See more</Trans>
|
||||
</InlineLinkText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{gtMobile ? (
|
||||
@@ -406,19 +403,16 @@ export function ProfileGrid({
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
<View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}>
|
||||
{content}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
|
||||
<SeeMoreSuggestedProfilesCard />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
{!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</View>
|
||||
@@ -427,7 +421,6 @@ export function ProfileGrid({
|
||||
|
||||
function SeeMoreSuggestedProfilesCard() {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
@@ -437,7 +430,7 @@ function SeeMoreSuggestedProfilesCard() {
|
||||
onPress={() => {
|
||||
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_col, a.align_center, a.gap_md]}>
|
||||
<Text style={[a.leading_snug, a.text_center]}>
|
||||
@@ -491,10 +484,7 @@ export function SuggestedFeeds() {
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||
<FeedCard.Outer>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.Avatar src={feed.avatar} />
|
||||
@@ -568,7 +558,7 @@ export function SuggestedFeeds() {
|
||||
navigation.navigate('SearchTab')
|
||||
}}
|
||||
style={[a.flex_col]}>
|
||||
<CardOuter style={[a.flex_1]}>
|
||||
<CardOuter>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.px_lg]}>
|
||||
<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({
|
||||
profile,
|
||||
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'
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'post_follow_profile_suggested_accounts'
|
||||
| 'post_threads_v2_unspecced'
|
||||
| 'remove_show_latest_button'
|
||||
| '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 {
|
||||
type AppBskyActorDefs,
|
||||
@@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog'
|
||||
import {ProfileHeaderHandle} from './Handle'
|
||||
import {ProfileHeaderMetrics} from './Metrics'
|
||||
import {ProfileHeaderShell} from './Shell'
|
||||
import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
|
||||
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
@@ -73,6 +74,7 @@ let ProfileHeaderStandard = ({
|
||||
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
const unblockPromptControl = Prompt.usePromptControl()
|
||||
const requireAuth = useRequireAuth()
|
||||
const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
|
||||
const isBlockedUser =
|
||||
profile.viewer?.blocking ||
|
||||
profile.viewer?.blockedBy ||
|
||||
@@ -81,6 +83,7 @@ let ProfileHeaderStandard = ({
|
||||
const editProfileControl = useDialogControl()
|
||||
|
||||
const onPressFollow = () => {
|
||||
setShowSuggestedFollows(true)
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
await queueFollow()
|
||||
@@ -102,6 +105,7 @@ let ProfileHeaderStandard = ({
|
||||
}
|
||||
|
||||
const onPressUnfollow = () => {
|
||||
setShowSuggestedFollows(false)
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
await queueUnfollow()
|
||||
@@ -122,7 +126,7 @@ let ProfileHeaderStandard = ({
|
||||
})
|
||||
}
|
||||
|
||||
const unblockAccount = React.useCallback(async () => {
|
||||
const unblockAccount = useCallback(async () => {
|
||||
try {
|
||||
await queueUnblock()
|
||||
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
|
||||
@@ -155,174 +159,185 @@ let ProfileHeaderStandard = ({
|
||||
}, [profile])
|
||||
|
||||
return (
|
||||
<ProfileHeaderShell
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<>
|
||||
<ProfileHeaderShell
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View
|
||||
style={[
|
||||
{paddingLeft: 90},
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_end,
|
||||
a.gap_xs,
|
||||
a.pb_sm,
|
||||
a.flex_wrap,
|
||||
]}
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
{isMe ? (
|
||||
<>
|
||||
<Button
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={editProfileControl.open}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<EditProfileDialog
|
||||
profile={profile}
|
||||
control={editProfileControl}
|
||||
/>
|
||||
</>
|
||||
) : profile.viewer?.blocking ? (
|
||||
profile.viewer?.blockingByList ? null : (
|
||||
<Button
|
||||
testID="unblockBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
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
|
||||
<View
|
||||
style={[
|
||||
{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'}>
|
||||
{isMe ? (
|
||||
<>
|
||||
<Button
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={editProfileControl.open}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<EditProfileDialog
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
control={editProfileControl}
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
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.viewer?.blocking ? (
|
||||
profile.viewer?.blockingByList ? null : (
|
||||
<Button
|
||||
testID="unblockBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
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}
|
||||
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>
|
||||
<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"
|
||||
<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}
|
||||
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)
|
||||
export {ProfileHeaderStandard}
|
||||
|
||||
@@ -211,7 +211,7 @@ let ProfileHeaderShell = ({
|
||||
|
||||
{!isPlaceholderProfile && (
|
||||
<View
|
||||
style={[a.px_lg, a.py_xs]}
|
||||
style={[a.px_lg, a.pt_xs, a.pb_sm]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
{isMe ? (
|
||||
<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 {
|
||||
AppBskyActorDefs,
|
||||
AppBskyActorGetSuggestions,
|
||||
AppBskyGraphGetSuggestedFollowsByActor,
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyActorGetSuggestions,
|
||||
type AppBskyGraphGetSuggestedFollowsByActor,
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
@@ -106,12 +106,15 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||
export function useSuggestedFollowsByActorQuery({
|
||||
did,
|
||||
enabled,
|
||||
staleTime = STALE.MINUTES.FIVE,
|
||||
}: {
|
||||
did: string
|
||||
enabled?: boolean
|
||||
staleTime?: number
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
return useQuery({
|
||||
staleTime,
|
||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||
|
||||
Reference in New Issue
Block a user