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 iconzio/stable
parent
498c3e2c27
commit
6df1bcad31
|
@ -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]),
|
||||
}
|
||||
}
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
Loading…
Reference in New Issue