Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Eric Bailey and committed by
GitHub
6df1bcad 498c3e2c

+406 -34
+46
src/lib/hooks/useFollowDid.ts
··· 1 + import React from 'react' 2 + 3 + import {useStores} from 'state/index' 4 + import {FollowState} from 'state/models/cache/my-follows' 5 + 6 + export function useFollowDid({did}: {did: string}) { 7 + const store = useStores() 8 + const state = store.me.follows.getFollowState(did) 9 + 10 + return { 11 + state, 12 + following: state === FollowState.Following, 13 + toggle: React.useCallback(async () => { 14 + if (state === FollowState.Following) { 15 + try { 16 + await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) 17 + store.me.follows.removeFollow(did) 18 + return { 19 + state: FollowState.NotFollowing, 20 + following: false, 21 + } 22 + } catch (e: any) { 23 + store.log.error('Failed to delete follow', e) 24 + throw e 25 + } 26 + } else if (state === FollowState.NotFollowing) { 27 + try { 28 + const res = await store.agent.follow(did) 29 + store.me.follows.addFollow(did, res.uri) 30 + return { 31 + state: FollowState.Following, 32 + following: true, 33 + } 34 + } catch (e: any) { 35 + store.log.error('Failed to create follow', e) 36 + throw e 37 + } 38 + } 39 + 40 + return { 41 + state: FollowState.Unknown, 42 + following: false, 43 + } 44 + }, [store, did, state]), 45 + } 46 + }
+6 -1
src/view/com/modals/ProfilePreview.tsx
··· 41 41 styles.headerWrapper, 42 42 isLoading && isIOS && styles.headerPositionAdjust, 43 43 ]}> 44 - <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} /> 44 + <ProfileHeader 45 + view={model} 46 + hideBackButton 47 + onRefreshAll={() => {}} 48 + isProfilePreview 49 + /> 45 50 </View> 46 51 <View style={[styles.hintWrapper, pal.view]}> 47 52 <View style={styles.hint}>
+15 -32
src/view/com/profile/FollowButton.tsx
··· 2 2 import {StyleProp, TextStyle, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import {Button, ButtonType} from '../util/forms/Button' 5 - import {useStores} from 'state/index' 6 5 import * as Toast from '../util/Toast' 7 6 import {FollowState} from 'state/models/cache/my-follows' 7 + import {useFollowDid} from 'lib/hooks/useFollowDid' 8 8 9 9 export const FollowButton = observer(function FollowButtonImpl({ 10 10 unfollowedType = 'inverted', ··· 19 19 onToggleFollow?: (v: boolean) => void 20 20 labelStyle?: StyleProp<TextStyle> 21 21 }) { 22 - const store = useStores() 23 - const followState = store.me.follows.getFollowState(did) 22 + const {state, following, toggle} = useFollowDid({did}) 24 23 25 - if (followState === FollowState.Unknown) { 24 + const onPress = React.useCallback(async () => { 25 + try { 26 + const {following} = await toggle() 27 + onToggleFollow?.(following) 28 + } catch (e: any) { 29 + Toast.show('An issue occurred, please try again.') 30 + } 31 + }, [toggle, onToggleFollow]) 32 + 33 + if (state === FollowState.Unknown) { 26 34 return <View /> 27 35 } 28 36 29 - const onToggleFollowInner = async () => { 30 - const updatedFollowState = await store.me.follows.fetchFollowState(did) 31 - if (updatedFollowState === FollowState.Following) { 32 - try { 33 - onToggleFollow?.(false) 34 - await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) 35 - store.me.follows.removeFollow(did) 36 - } catch (e: any) { 37 - store.log.error('Failed to delete follow', e) 38 - Toast.show('An issue occurred, please try again.') 39 - } 40 - } else if (updatedFollowState === FollowState.NotFollowing) { 41 - try { 42 - onToggleFollow?.(true) 43 - const res = await store.agent.follow(did) 44 - store.me.follows.addFollow(did, res.uri) 45 - } catch (e: any) { 46 - store.log.error('Failed to create follow', e) 47 - Toast.show('An issue occurred, please try again.') 48 - } 49 - } 50 - } 51 - 52 37 return ( 53 38 <Button 54 - type={ 55 - followState === FollowState.Following ? followedType : unfollowedType 56 - } 39 + type={following ? followedType : unfollowedType} 57 40 labelStyle={labelStyle} 58 - onPress={onToggleFollowInner} 59 - label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} 41 + onPress={onPress} 42 + label={following ? 'Unfollow' : 'Follow'} 60 43 withLoading={true} 61 44 /> 62 45 )
+51 -1
src/view/com/profile/ProfileHeader.tsx
··· 38 38 import {isInvalidHandle} from 'lib/strings/handles' 39 39 import {makeProfileLink} from 'lib/routes/links' 40 40 import {Link} from '../util/Link' 41 + import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' 41 42 42 43 interface Props { 43 44 view: ProfileModel 44 45 onRefreshAll: () => void 45 46 hideBackButton?: boolean 47 + isProfilePreview?: boolean 46 48 } 47 49 48 50 export const ProfileHeader = observer(function ProfileHeaderImpl({ 49 51 view, 50 52 onRefreshAll, 51 53 hideBackButton = false, 54 + isProfilePreview, 52 55 }: Props) { 53 56 const pal = usePalette('default') 54 57 ··· 95 98 view={view} 96 99 onRefreshAll={onRefreshAll} 97 100 hideBackButton={hideBackButton} 101 + isProfilePreview={isProfilePreview} 98 102 /> 99 103 ) 100 104 }) ··· 103 107 view, 104 108 onRefreshAll, 105 109 hideBackButton = false, 110 + isProfilePreview, 106 111 }: Props) { 107 112 const pal = usePalette('default') 108 113 const palInverted = usePalette('inverted') ··· 111 116 const {track} = useAnalytics() 112 117 const invalidHandle = isInvalidHandle(view.handle) 113 118 const {isDesktop} = useWebMediaQueries() 119 + const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) 114 120 115 121 const onPressBack = React.useCallback(() => { 116 122 navigation.goBack() ··· 133 139 ) 134 140 view?.toggleFollowing().then( 135 141 () => { 142 + setShowSuggestedFollows(Boolean(view.viewer.following)) 143 + 136 144 Toast.show( 137 145 `${ 138 146 view.viewer.following ? 'Following' : 'No longer following' ··· 141 149 }, 142 150 err => store.log.error('Failed to toggle follow', err), 143 151 ) 144 - }, [track, view, store.log]) 152 + }, [track, view, store.log, setShowSuggestedFollows]) 145 153 146 154 const onPressEditProfile = React.useCallback(() => { 147 155 track('ProfileHeader:EditProfileButtonClicked') ··· 373 381 </TouchableOpacity> 374 382 ) : !view.viewer.blockedBy ? ( 375 383 <> 384 + {!isProfilePreview && ( 385 + <TouchableOpacity 386 + testID="suggestedFollowsBtn" 387 + onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} 388 + style={[ 389 + styles.btn, 390 + styles.mainBtn, 391 + pal.btn, 392 + { 393 + paddingHorizontal: 10, 394 + backgroundColor: showSuggestedFollows 395 + ? colors.blue3 396 + : pal.viewLight.backgroundColor, 397 + }, 398 + ]} 399 + accessibilityRole="button" 400 + accessibilityLabel={`Show follows similar to ${view.handle}`} 401 + accessibilityHint={`Shows a list of users similar to this user.`}> 402 + <FontAwesomeIcon 403 + icon="user-plus" 404 + style={[ 405 + pal.text, 406 + { 407 + color: showSuggestedFollows 408 + ? colors.white 409 + : pal.text.color, 410 + }, 411 + ]} 412 + size={14} 413 + /> 414 + </TouchableOpacity> 415 + )} 416 + 376 417 {store.me.follows.getFollowState(view.did) === 377 418 FollowState.Following ? ( 378 419 <TouchableOpacity ··· 504 545 )} 505 546 <ProfileHeaderAlerts moderation={view.moderation} /> 506 547 </View> 548 + 549 + {!isProfilePreview && ( 550 + <ProfileHeaderSuggestedFollows 551 + actorDid={view.did} 552 + active={showSuggestedFollows} 553 + requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)} 554 + /> 555 + )} 556 + 507 557 {!isDesktop && !hideBackButton && ( 508 558 <TouchableWithoutFeedback 509 559 onPress={onPressBack}
+288
src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
··· 1 + import React from 'react' 2 + import {View, StyleSheet, ScrollView, Pressable} from 'react-native' 3 + import Animated, { 4 + useSharedValue, 5 + withTiming, 6 + useAnimatedStyle, 7 + Easing, 8 + } from 'react-native-reanimated' 9 + import {useQuery} from '@tanstack/react-query' 10 + import {AppBskyActorDefs, moderateProfile} from '@atproto/api' 11 + import {observer} from 'mobx-react-lite' 12 + import { 13 + FontAwesomeIcon, 14 + FontAwesomeIconStyle, 15 + } from '@fortawesome/react-native-fontawesome' 16 + 17 + import * as Toast from '../util/Toast' 18 + import {useStores} from 'state/index' 19 + import {usePalette} from 'lib/hooks/usePalette' 20 + import {Text} from 'view/com/util/text/Text' 21 + import {UserAvatar} from 'view/com/util/UserAvatar' 22 + import {useFollowDid} from 'lib/hooks/useFollowDid' 23 + import {Button} from 'view/com/util/forms/Button' 24 + import {sanitizeDisplayName} from 'lib/strings/display-names' 25 + import {sanitizeHandle} from 'lib/strings/handles' 26 + import {makeProfileLink} from 'lib/routes/links' 27 + import {Link} from 'view/com/util/Link' 28 + 29 + const OUTER_PADDING = 10 30 + const INNER_PADDING = 14 31 + const TOTAL_HEIGHT = 250 32 + 33 + export function ProfileHeaderSuggestedFollows({ 34 + actorDid, 35 + active, 36 + requestDismiss, 37 + }: { 38 + actorDid: string 39 + active: boolean 40 + requestDismiss: () => void 41 + }) { 42 + const pal = usePalette('default') 43 + const store = useStores() 44 + const animatedHeight = useSharedValue(0) 45 + const animatedStyles = useAnimatedStyle(() => ({ 46 + opacity: animatedHeight.value / TOTAL_HEIGHT, 47 + height: animatedHeight.value, 48 + })) 49 + 50 + React.useEffect(() => { 51 + if (active) { 52 + animatedHeight.value = withTiming(TOTAL_HEIGHT, { 53 + duration: 500, 54 + easing: Easing.inOut(Easing.exp), 55 + }) 56 + } else { 57 + animatedHeight.value = withTiming(0, { 58 + duration: 500, 59 + easing: Easing.inOut(Easing.exp), 60 + }) 61 + } 62 + }, [active, animatedHeight]) 63 + 64 + const {isLoading, data: suggestedFollows} = useQuery({ 65 + enabled: active, 66 + cacheTime: 0, 67 + staleTime: 0, 68 + queryKey: ['suggested_follows_by_actor', actorDid], 69 + async queryFn() { 70 + try { 71 + const { 72 + data: {suggestions}, 73 + success, 74 + } = await store.agent.app.bsky.graph.getSuggestedFollowsByActor({ 75 + actor: actorDid, 76 + }) 77 + 78 + if (!success) { 79 + return [] 80 + } 81 + 82 + store.me.follows.hydrateProfiles(suggestions) 83 + 84 + return suggestions 85 + } catch (e) { 86 + return [] 87 + } 88 + }, 89 + }) 90 + 91 + return ( 92 + <Animated.View style={[{overflow: 'hidden', opacity: 0}, animatedStyles]}> 93 + <View style={{paddingVertical: OUTER_PADDING}}> 94 + <View 95 + style={{ 96 + backgroundColor: pal.viewLight.backgroundColor, 97 + height: '100%', 98 + paddingTop: INNER_PADDING / 2, 99 + paddingBottom: INNER_PADDING, 100 + }}> 101 + <View 102 + style={{ 103 + flexDirection: 'row', 104 + justifyContent: 'space-between', 105 + alignItems: 'center', 106 + paddingTop: 4, 107 + paddingBottom: INNER_PADDING / 2, 108 + paddingLeft: INNER_PADDING, 109 + paddingRight: INNER_PADDING / 2, 110 + }}> 111 + <Text type="sm-bold" style={[pal.textLight]}> 112 + Suggested for you 113 + </Text> 114 + 115 + <Pressable 116 + accessibilityRole="button" 117 + onPress={requestDismiss} 118 + hitSlop={10} 119 + style={{padding: INNER_PADDING / 2}}> 120 + <FontAwesomeIcon 121 + icon="x" 122 + size={12} 123 + style={pal.textLight as FontAwesomeIconStyle} 124 + /> 125 + </Pressable> 126 + </View> 127 + 128 + <ScrollView 129 + horizontal 130 + showsHorizontalScrollIndicator={false} 131 + contentContainerStyle={{ 132 + alignItems: 'flex-start', 133 + paddingLeft: INNER_PADDING / 2, 134 + }}> 135 + {isLoading ? ( 136 + <> 137 + <SuggestedFollowSkeleton /> 138 + <SuggestedFollowSkeleton /> 139 + <SuggestedFollowSkeleton /> 140 + <SuggestedFollowSkeleton /> 141 + <SuggestedFollowSkeleton /> 142 + <SuggestedFollowSkeleton /> 143 + </> 144 + ) : suggestedFollows ? ( 145 + suggestedFollows.map(profile => ( 146 + <SuggestedFollow key={profile.did} profile={profile} /> 147 + )) 148 + ) : ( 149 + <View /> 150 + )} 151 + </ScrollView> 152 + </View> 153 + </View> 154 + </Animated.View> 155 + ) 156 + } 157 + 158 + function SuggestedFollowSkeleton() { 159 + const pal = usePalette('default') 160 + return ( 161 + <View 162 + style={[ 163 + styles.suggestedFollowCardOuter, 164 + { 165 + backgroundColor: pal.view.backgroundColor, 166 + }, 167 + ]}> 168 + <View 169 + style={{ 170 + height: 60, 171 + width: 60, 172 + borderRadius: 60, 173 + backgroundColor: pal.viewLight.backgroundColor, 174 + opacity: 0.6, 175 + }} 176 + /> 177 + <View 178 + style={{ 179 + height: 17, 180 + width: 70, 181 + borderRadius: 4, 182 + backgroundColor: pal.viewLight.backgroundColor, 183 + marginTop: 12, 184 + marginBottom: 4, 185 + }} 186 + /> 187 + <View 188 + style={{ 189 + height: 12, 190 + width: 70, 191 + borderRadius: 4, 192 + backgroundColor: pal.viewLight.backgroundColor, 193 + marginBottom: 12, 194 + opacity: 0.6, 195 + }} 196 + /> 197 + <View 198 + style={{ 199 + height: 32, 200 + borderRadius: 32, 201 + width: '100%', 202 + backgroundColor: pal.viewLight.backgroundColor, 203 + }} 204 + /> 205 + </View> 206 + ) 207 + } 208 + 209 + const SuggestedFollow = observer(function SuggestedFollowImpl({ 210 + profile, 211 + }: { 212 + profile: AppBskyActorDefs.ProfileView 213 + }) { 214 + const pal = usePalette('default') 215 + const store = useStores() 216 + const {following, toggle} = useFollowDid({did: profile.did}) 217 + const moderation = moderateProfile(profile, store.preferences.moderationOpts) 218 + 219 + const onPress = React.useCallback(async () => { 220 + try { 221 + await toggle() 222 + } catch (e: any) { 223 + Toast.show('An issue occurred, please try again.') 224 + } 225 + }, [toggle]) 226 + 227 + return ( 228 + <Link 229 + href={makeProfileLink(profile)} 230 + title={profile.handle} 231 + asAnchor 232 + anchorNoUnderline> 233 + <View 234 + style={[ 235 + styles.suggestedFollowCardOuter, 236 + { 237 + backgroundColor: pal.view.backgroundColor, 238 + }, 239 + ]}> 240 + <UserAvatar 241 + size={60} 242 + avatar={profile.avatar} 243 + moderation={moderation.avatar} 244 + /> 245 + 246 + <View style={{width: '100%', paddingVertical: 12}}> 247 + <Text 248 + type="xs-medium" 249 + style={[pal.text, {textAlign: 'center'}]} 250 + numberOfLines={1}> 251 + {sanitizeDisplayName( 252 + profile.displayName || sanitizeHandle(profile.handle), 253 + moderation.profile, 254 + )} 255 + </Text> 256 + <Text 257 + type="xs-medium" 258 + style={[pal.textLight, {textAlign: 'center'}]} 259 + numberOfLines={1}> 260 + {sanitizeHandle(profile.handle, '@')} 261 + </Text> 262 + </View> 263 + 264 + <Button 265 + label={following ? 'Unfollow' : 'Follow'} 266 + type="inverted" 267 + labelStyle={{textAlign: 'center'}} 268 + onPress={onPress} 269 + withLoading 270 + /> 271 + </View> 272 + </Link> 273 + ) 274 + }) 275 + 276 + const styles = StyleSheet.create({ 277 + suggestedFollowCardOuter: { 278 + marginHorizontal: INNER_PADDING / 2, 279 + paddingTop: 10, 280 + paddingBottom: 12, 281 + paddingHorizontal: 10, 282 + borderRadius: 8, 283 + width: 130, 284 + alignItems: 'center', 285 + overflow: 'hidden', 286 + flexShrink: 1, 287 + }, 288 + })