add suggested follow section to profile header (#1481)

* add suggested follow section to profile header

* fix button overflow

* don't even render on preview

* fix useFollowDid and FollowButton race condition

* add section header, close button, active state

* lighten icon
zio/stable
Eric Bailey 2023-09-20 21:16:11 -05:00 committed by GitHub
parent 498c3e2c27
commit 6df1bcad31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 406 additions and 34 deletions

View File

@ -0,0 +1,46 @@
import React from 'react'
import {useStores} from 'state/index'
import {FollowState} from 'state/models/cache/my-follows'
export function useFollowDid({did}: {did: string}) {
const store = useStores()
const state = store.me.follows.getFollowState(did)
return {
state,
following: state === FollowState.Following,
toggle: React.useCallback(async () => {
if (state === FollowState.Following) {
try {
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did)
return {
state: FollowState.NotFollowing,
following: false,
}
} catch (e: any) {
store.log.error('Failed to delete follow', e)
throw e
}
} else if (state === FollowState.NotFollowing) {
try {
const res = await store.agent.follow(did)
store.me.follows.addFollow(did, res.uri)
return {
state: FollowState.Following,
following: true,
}
} catch (e: any) {
store.log.error('Failed to create follow', e)
throw e
}
}
return {
state: FollowState.Unknown,
following: false,
}
}, [store, did, state]),
}
}

View File

@ -41,7 +41,12 @@ export const Component = observer(function ProfilePreviewImpl({
styles.headerWrapper,
isLoading && isIOS && styles.headerPositionAdjust,
]}>
<ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} />
<ProfileHeader
view={model}
hideBackButton
onRefreshAll={() => {}}
isProfilePreview
/>
</View>
<View style={[styles.hintWrapper, pal.view]}>
<View style={styles.hint}>

View File

@ -2,9 +2,9 @@ import React from 'react'
import {StyleProp, TextStyle, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {Button, ButtonType} from '../util/forms/Button'
import {useStores} from 'state/index'
import * as Toast from '../util/Toast'
import {FollowState} from 'state/models/cache/my-follows'
import {useFollowDid} from 'lib/hooks/useFollowDid'
export const FollowButton = observer(function FollowButtonImpl({
unfollowedType = 'inverted',
@ -19,44 +19,27 @@ export const FollowButton = observer(function FollowButtonImpl({
onToggleFollow?: (v: boolean) => void
labelStyle?: StyleProp<TextStyle>
}) {
const store = useStores()
const followState = store.me.follows.getFollowState(did)
const {state, following, toggle} = useFollowDid({did})
if (followState === FollowState.Unknown) {
return <View />
}
const onToggleFollowInner = async () => {
const updatedFollowState = await store.me.follows.fetchFollowState(did)
if (updatedFollowState === FollowState.Following) {
try {
onToggleFollow?.(false)
await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did)
} catch (e: any) {
store.log.error('Failed to delete follow', e)
Toast.show('An issue occurred, please try again.')
}
} else if (updatedFollowState === FollowState.NotFollowing) {
try {
onToggleFollow?.(true)
const res = await store.agent.follow(did)
store.me.follows.addFollow(did, res.uri)
} catch (e: any) {
store.log.error('Failed to create follow', e)
Toast.show('An issue occurred, please try again.')
}
const onPress = React.useCallback(async () => {
try {
const {following} = await toggle()
onToggleFollow?.(following)
} catch (e: any) {
Toast.show('An issue occurred, please try again.')
}
}, [toggle, onToggleFollow])
if (state === FollowState.Unknown) {
return <View />
}
return (
<Button
type={
followState === FollowState.Following ? followedType : unfollowedType
}
type={following ? followedType : unfollowedType}
labelStyle={labelStyle}
onPress={onToggleFollowInner}
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
onPress={onPress}
label={following ? 'Unfollow' : 'Follow'}
withLoading={true}
/>
)

View File

@ -38,17 +38,20 @@ import {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {Link} from '../util/Link'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
interface Props {
view: ProfileModel
onRefreshAll: () => void
hideBackButton?: boolean
isProfilePreview?: boolean
}
export const ProfileHeader = observer(function ProfileHeaderImpl({
view,
onRefreshAll,
hideBackButton = false,
isProfilePreview,
}: Props) {
const pal = usePalette('default')
@ -95,6 +98,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
view={view}
onRefreshAll={onRefreshAll}
hideBackButton={hideBackButton}
isProfilePreview={isProfilePreview}
/>
)
})
@ -103,6 +107,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
view,
onRefreshAll,
hideBackButton = false,
isProfilePreview,
}: Props) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
@ -111,6 +116,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
const {track} = useAnalytics()
const invalidHandle = isInvalidHandle(view.handle)
const {isDesktop} = useWebMediaQueries()
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const onPressBack = React.useCallback(() => {
navigation.goBack()
@ -133,6 +139,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
)
view?.toggleFollowing().then(
() => {
setShowSuggestedFollows(Boolean(view.viewer.following))
Toast.show(
`${
view.viewer.following ? 'Following' : 'No longer following'
@ -141,7 +149,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
},
err => store.log.error('Failed to toggle follow', err),
)
}, [track, view, store.log])
}, [track, view, store.log, setShowSuggestedFollows])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
@ -373,6 +381,39 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
</TouchableOpacity>
) : !view.viewer.blockedBy ? (
<>
{!isProfilePreview && (
<TouchableOpacity
testID="suggestedFollowsBtn"
onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
style={[
styles.btn,
styles.mainBtn,
pal.btn,
{
paddingHorizontal: 10,
backgroundColor: showSuggestedFollows
? colors.blue3
: pal.viewLight.backgroundColor,
},
]}
accessibilityRole="button"
accessibilityLabel={`Show follows similar to ${view.handle}`}
accessibilityHint={`Shows a list of users similar to this user.`}>
<FontAwesomeIcon
icon="user-plus"
style={[
pal.text,
{
color: showSuggestedFollows
? colors.white
: pal.text.color,
},
]}
size={14}
/>
</TouchableOpacity>
)}
{store.me.follows.getFollowState(view.did) ===
FollowState.Following ? (
<TouchableOpacity
@ -504,6 +545,15 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
)}
<ProfileHeaderAlerts moderation={view.moderation} />
</View>
{!isProfilePreview && (
<ProfileHeaderSuggestedFollows
actorDid={view.did}
active={showSuggestedFollows}
requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)}
/>
)}
{!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback
onPress={onPressBack}

View File

@ -0,0 +1,288 @@
import React from 'react'
import {View, StyleSheet, ScrollView, Pressable} from 'react-native'
import Animated, {
useSharedValue,
withTiming,
useAnimatedStyle,
Easing,
} from 'react-native-reanimated'
import {useQuery} from '@tanstack/react-query'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {useFollowDid} from 'lib/hooks/useFollowDid'
import {Button} from 'view/com/util/forms/Button'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {Link} from 'view/com/util/Link'
const OUTER_PADDING = 10
const INNER_PADDING = 14
const TOTAL_HEIGHT = 250
export function ProfileHeaderSuggestedFollows({
actorDid,
active,
requestDismiss,
}: {
actorDid: string
active: boolean
requestDismiss: () => void
}) {
const pal = usePalette('default')
const store = useStores()
const animatedHeight = useSharedValue(0)
const animatedStyles = useAnimatedStyle(() => ({
opacity: animatedHeight.value / TOTAL_HEIGHT,
height: animatedHeight.value,
}))
React.useEffect(() => {
if (active) {
animatedHeight.value = withTiming(TOTAL_HEIGHT, {
duration: 500,
easing: Easing.inOut(Easing.exp),
})
} else {
animatedHeight.value = withTiming(0, {
duration: 500,
easing: Easing.inOut(Easing.exp),
})
}
}, [active, animatedHeight])
const {isLoading, data: suggestedFollows} = useQuery({
enabled: active,
cacheTime: 0,
staleTime: 0,
queryKey: ['suggested_follows_by_actor', actorDid],
async queryFn() {
try {
const {
data: {suggestions},
success,
} = await store.agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: actorDid,
})
if (!success) {
return []
}
store.me.follows.hydrateProfiles(suggestions)
return suggestions
} catch (e) {
return []
}
},
})
return (
<Animated.View style={[{overflow: 'hidden', opacity: 0}, animatedStyles]}>
<View style={{paddingVertical: OUTER_PADDING}}>
<View
style={{
backgroundColor: pal.viewLight.backgroundColor,
height: '100%',
paddingTop: INNER_PADDING / 2,
paddingBottom: INNER_PADDING,
}}>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 4,
paddingBottom: INNER_PADDING / 2,
paddingLeft: INNER_PADDING,
paddingRight: INNER_PADDING / 2,
}}>
<Text type="sm-bold" style={[pal.textLight]}>
Suggested for you
</Text>
<Pressable
accessibilityRole="button"
onPress={requestDismiss}
hitSlop={10}
style={{padding: INNER_PADDING / 2}}>
<FontAwesomeIcon
icon="x"
size={12}
style={pal.textLight as FontAwesomeIconStyle}
/>
</Pressable>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
alignItems: 'flex-start',
paddingLeft: INNER_PADDING / 2,
}}>
{isLoading ? (
<>
<SuggestedFollowSkeleton />
<SuggestedFollowSkeleton />
<SuggestedFollowSkeleton />
<SuggestedFollowSkeleton />
<SuggestedFollowSkeleton />
<SuggestedFollowSkeleton />
</>
) : suggestedFollows ? (
suggestedFollows.map(profile => (
<SuggestedFollow key={profile.did} profile={profile} />
))
) : (
<View />
)}
</ScrollView>
</View>
</View>
</Animated.View>
)
}
function SuggestedFollowSkeleton() {
const pal = usePalette('default')
return (
<View
style={[
styles.suggestedFollowCardOuter,
{
backgroundColor: pal.view.backgroundColor,
},
]}>
<View
style={{
height: 60,
width: 60,
borderRadius: 60,
backgroundColor: pal.viewLight.backgroundColor,
opacity: 0.6,
}}
/>
<View
style={{
height: 17,
width: 70,
borderRadius: 4,
backgroundColor: pal.viewLight.backgroundColor,
marginTop: 12,
marginBottom: 4,
}}
/>
<View
style={{
height: 12,
width: 70,
borderRadius: 4,
backgroundColor: pal.viewLight.backgroundColor,
marginBottom: 12,
opacity: 0.6,
}}
/>
<View
style={{
height: 32,
borderRadius: 32,
width: '100%',
backgroundColor: pal.viewLight.backgroundColor,
}}
/>
</View>
)
}
const SuggestedFollow = observer(function SuggestedFollowImpl({
profile,
}: {
profile: AppBskyActorDefs.ProfileView
}) {
const pal = usePalette('default')
const store = useStores()
const {following, toggle} = useFollowDid({did: profile.did})
const moderation = moderateProfile(profile, store.preferences.moderationOpts)
const onPress = React.useCallback(async () => {
try {
await toggle()
} catch (e: any) {
Toast.show('An issue occurred, please try again.')
}
}, [toggle])
return (
<Link
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline>
<View
style={[
styles.suggestedFollowCardOuter,
{
backgroundColor: pal.view.backgroundColor,
},
]}>
<UserAvatar
size={60}
avatar={profile.avatar}
moderation={moderation.avatar}
/>
<View style={{width: '100%', paddingVertical: 12}}>
<Text
type="xs-medium"
style={[pal.text, {textAlign: 'center'}]}
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.profile,
)}
</Text>
<Text
type="xs-medium"
style={[pal.textLight, {textAlign: 'center'}]}
numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
<Button
label={following ? 'Unfollow' : 'Follow'}
type="inverted"
labelStyle={{textAlign: 'center'}}
onPress={onPress}
withLoading
/>
</View>
</Link>
)
})
const styles = StyleSheet.create({
suggestedFollowCardOuter: {
marginHorizontal: INNER_PADDING / 2,
paddingTop: 10,
paddingBottom: 12,
paddingHorizontal: 10,
borderRadius: 8,
width: 130,
alignItems: 'center',
overflow: 'hidden',
flexShrink: 1,
},
})