Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

ALF suggested follows in profile header (#4828)

* Refactor ProfileHeaderSuggestedFollows

* Load fresh data every time

* Oops, missed a file

* Update ProfileCard.Link usage, tweak copy

authored by

Eric Bailey and committed by
GitHub
1e3b2d6f af526268

+155 -229
+4
src/lib/statsig/events.ts
··· 159 159 | 'AvatarButton' 160 160 | 'StarterPackProfilesList' 161 161 | 'FeedInterstitial' 162 + | 'ProfileHeaderSuggestedFollows' 162 163 } 163 164 'profile:unfollow': { 164 165 logContext: ··· 173 174 | 'AvatarButton' 174 175 | 'StarterPackProfilesList' 175 176 | 'FeedInterstitial' 177 + | 'ProfileHeaderSuggestedFollows' 176 178 } 177 179 'chat:create': { 178 180 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' ··· 210 212 211 213 'feed:interstitial:profileCard:press': {} 212 214 'feed:interstitial:feedCard:press': {} 215 + 216 + 'profile:header:suggestedFollowsCard:press': {} 213 217 214 218 'debug:followingPrefs': { 215 219 followingShowRepliesFromPref: 'all' | 'following' | 'off'
+1
src/state/queries/suggested-follows.ts
··· 106 106 export function useSuggestedFollowsByActorQuery({did}: {did: string}) { 107 107 const agent = useAgent() 108 108 return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ 109 + gcTime: 0, 109 110 queryKey: suggestedFollowsByActorQueryKey(did), 110 111 queryFn: async () => { 111 112 const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
+150 -229
src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
··· 1 1 import React from 'react' 2 - import {Pressable, ScrollView, StyleSheet, View} from 'react-native' 3 - import {AppBskyActorDefs, moderateProfile} from '@atproto/api' 4 - import { 5 - FontAwesomeIcon, 6 - FontAwesomeIconStyle, 7 - } from '@fortawesome/react-native-fontawesome' 2 + import {ScrollView, View} from 'react-native' 8 3 import {msg, Trans} from '@lingui/macro' 9 4 import {useLingui} from '@lingui/react' 10 5 11 - import {useProfileShadow} from '#/state/cache/profile-shadow' 6 + import {logEvent} from '#/lib/statsig/statsig' 12 7 import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 - import {useProfileFollowMutationQueue} from '#/state/queries/profile' 14 8 import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 15 - import {useAnalytics} from 'lib/analytics/analytics' 16 - import {usePalette} from 'lib/hooks/usePalette' 17 - import {makeProfileLink} from 'lib/routes/links' 18 - import {sanitizeDisplayName} from 'lib/strings/display-names' 19 - import {sanitizeHandle} from 'lib/strings/handles' 20 9 import {isWeb} from 'platform/detection' 21 - import {Button} from 'view/com/util/forms/Button' 22 - import {Link} from 'view/com/util/Link' 23 - import {Text} from 'view/com/util/text/Text' 24 - import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' 25 - import * as Toast from '../util/Toast' 10 + import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 11 + import {Button, ButtonIcon} from '#/components/Button' 12 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 13 + import * as ProfileCard from '#/components/ProfileCard' 14 + import {Text} from '#/components/Typography' 15 + 16 + const OUTER_PADDING = a.p_md.padding 17 + const INNER_PADDING = a.p_lg.padding 18 + const TOTAL_HEIGHT = 232 19 + const MOBILE_CARD_WIDTH = 300 20 + 21 + function CardOuter({ 22 + children, 23 + style, 24 + }: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { 25 + const t = useTheme() 26 + return ( 27 + <View 28 + style={[ 29 + a.w_full, 30 + a.p_lg, 31 + a.rounded_md, 32 + a.border, 33 + t.atoms.bg, 34 + t.atoms.border_contrast_low, 35 + { 36 + width: MOBILE_CARD_WIDTH, 37 + }, 38 + style, 39 + ]}> 40 + {children} 41 + </View> 42 + ) 43 + } 26 44 27 - const OUTER_PADDING = 10 28 - const INNER_PADDING = 14 29 - const TOTAL_HEIGHT = 250 45 + export function SuggestedFollowPlaceholder() { 46 + const t = useTheme() 47 + return ( 48 + <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}> 49 + <ProfileCard.Header> 50 + <ProfileCard.AvatarPlaceholder /> 51 + <ProfileCard.NameAndHandlePlaceholder /> 52 + </ProfileCard.Header> 53 + 54 + <ProfileCard.DescriptionPlaceholder /> 55 + </CardOuter> 56 + ) 57 + } 30 58 31 59 export function ProfileHeaderSuggestedFollows({ 32 60 actorDid, ··· 35 63 actorDid: string 36 64 requestDismiss: () => void 37 65 }) { 38 - const pal = usePalette('default') 39 - const {isLoading, data} = useSuggestedFollowsByActorQuery({ 40 - did: actorDid, 41 - }) 66 + const t = useTheme() 67 + const {_} = useLingui() 68 + const {isLoading: isSuggestionsLoading, data} = 69 + useSuggestedFollowsByActorQuery({ 70 + did: actorDid, 71 + }) 72 + const moderationOpts = useModerationOpts() 73 + const isLoading = isSuggestionsLoading || !moderationOpts 74 + 42 75 return ( 43 76 <View 44 77 style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}} 45 78 pointerEvents="box-none"> 46 79 <View 47 80 pointerEvents="box-none" 48 - style={{ 49 - backgroundColor: pal.viewLight.backgroundColor, 50 - height: '100%', 51 - paddingTop: INNER_PADDING / 2, 52 - }}> 81 + style={[ 82 + t.atoms.bg_contrast_25, 83 + { 84 + height: '100%', 85 + paddingTop: INNER_PADDING / 2, 86 + }, 87 + ]}> 53 88 <View 54 89 pointerEvents="box-none" 55 - style={{ 56 - flexDirection: 'row', 57 - justifyContent: 'space-between', 58 - alignItems: 'center', 59 - paddingTop: 4, 60 - paddingBottom: INNER_PADDING / 2, 61 - paddingLeft: INNER_PADDING, 62 - paddingRight: INNER_PADDING / 2, 63 - }}> 64 - <Text type="sm-bold" style={[pal.textLight]}> 65 - <Trans>Suggested for you</Trans> 90 + style={[ 91 + a.flex_row, 92 + a.justify_between, 93 + a.align_center, 94 + a.pt_xs, 95 + { 96 + paddingBottom: INNER_PADDING / 2, 97 + paddingLeft: INNER_PADDING, 98 + paddingRight: INNER_PADDING / 2, 99 + }, 100 + ]}> 101 + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> 102 + <Trans>Similar accounts</Trans> 66 103 </Text> 67 104 68 - <Pressable 69 - accessibilityRole="button" 105 + <Button 70 106 onPress={requestDismiss} 71 107 hitSlop={10} 72 - style={{padding: INNER_PADDING / 2}}> 73 - <FontAwesomeIcon 74 - icon="x" 75 - size={12} 76 - style={pal.textLight as FontAwesomeIconStyle} 77 - /> 78 - </Pressable> 108 + label={_(msg`Dismiss`)} 109 + size="xsmall" 110 + variant="ghost" 111 + color="secondary" 112 + shape="round"> 113 + <ButtonIcon icon={X} size="sm" /> 114 + </Button> 79 115 </View> 80 116 81 117 <ScrollView ··· 83 119 showsHorizontalScrollIndicator={isWeb} 84 120 persistentScrollbar={true} 85 121 scrollIndicatorInsets={{bottom: 0}} 86 - scrollEnabled={true} 87 - contentContainerStyle={{ 88 - alignItems: 'flex-start', 89 - paddingLeft: INNER_PADDING / 2, 90 - paddingBottom: INNER_PADDING, 91 - }}> 92 - {isLoading ? ( 93 - <> 94 - <SuggestedFollowSkeleton /> 95 - <SuggestedFollowSkeleton /> 96 - <SuggestedFollowSkeleton /> 97 - <SuggestedFollowSkeleton /> 98 - <SuggestedFollowSkeleton /> 99 - <SuggestedFollowSkeleton /> 100 - </> 101 - ) : data ? ( 102 - data.suggestions 103 - .filter(s => (s.associated?.labeler ? false : true)) 104 - .map(profile => ( 105 - <SuggestedFollow key={profile.did} profile={profile} /> 106 - )) 107 - ) : ( 108 - <View /> 109 - )} 122 + snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap} 123 + decelerationRate="fast"> 124 + <View 125 + style={[ 126 + a.flex_row, 127 + a.gap_sm, 128 + { 129 + paddingHorizontal: INNER_PADDING, 130 + paddingBottom: INNER_PADDING, 131 + }, 132 + ]}> 133 + {isLoading ? ( 134 + <> 135 + <SuggestedFollowPlaceholder /> 136 + <SuggestedFollowPlaceholder /> 137 + <SuggestedFollowPlaceholder /> 138 + <SuggestedFollowPlaceholder /> 139 + <SuggestedFollowPlaceholder /> 140 + </> 141 + ) : data ? ( 142 + data.suggestions 143 + .filter(s => (s.associated?.labeler ? false : true)) 144 + .map(profile => ( 145 + <ProfileCard.Link 146 + key={profile.did} 147 + profile={profile} 148 + onPress={() => { 149 + logEvent('profile:header:suggestedFollowsCard:press', {}) 150 + }} 151 + style={[a.flex_1]}> 152 + {({hovered, pressed}) => ( 153 + <CardOuter 154 + style={[ 155 + a.flex_1, 156 + (hovered || pressed) && t.atoms.border_contrast_high, 157 + ]}> 158 + <ProfileCard.Outer> 159 + <ProfileCard.Header> 160 + <ProfileCard.Avatar 161 + profile={profile} 162 + moderationOpts={moderationOpts} 163 + /> 164 + <ProfileCard.NameAndHandle 165 + profile={profile} 166 + moderationOpts={moderationOpts} 167 + /> 168 + <ProfileCard.FollowButton 169 + profile={profile} 170 + moderationOpts={moderationOpts} 171 + logContext="ProfileHeaderSuggestedFollows" 172 + color="secondary_inverted" 173 + shape="round" 174 + /> 175 + </ProfileCard.Header> 176 + <ProfileCard.Description profile={profile} /> 177 + </ProfileCard.Outer> 178 + </CardOuter> 179 + )} 180 + </ProfileCard.Link> 181 + )) 182 + ) : ( 183 + <View /> 184 + )} 185 + </View> 110 186 </ScrollView> 111 187 </View> 112 188 </View> 113 189 ) 114 190 } 115 - 116 - function SuggestedFollowSkeleton() { 117 - const pal = usePalette('default') 118 - return ( 119 - <View 120 - style={[ 121 - styles.suggestedFollowCardOuter, 122 - { 123 - backgroundColor: pal.view.backgroundColor, 124 - }, 125 - ]}> 126 - <View 127 - style={{ 128 - height: 60, 129 - width: 60, 130 - borderRadius: 60, 131 - backgroundColor: pal.viewLight.backgroundColor, 132 - opacity: 0.6, 133 - }} 134 - /> 135 - <View 136 - style={{ 137 - height: 17, 138 - width: 70, 139 - borderRadius: 4, 140 - backgroundColor: pal.viewLight.backgroundColor, 141 - marginTop: 12, 142 - marginBottom: 4, 143 - }} 144 - /> 145 - <View 146 - style={{ 147 - height: 12, 148 - width: 70, 149 - borderRadius: 4, 150 - backgroundColor: pal.viewLight.backgroundColor, 151 - marginBottom: 12, 152 - opacity: 0.6, 153 - }} 154 - /> 155 - <View 156 - style={{ 157 - height: 32, 158 - borderRadius: 32, 159 - width: '100%', 160 - backgroundColor: pal.viewLight.backgroundColor, 161 - }} 162 - /> 163 - </View> 164 - ) 165 - } 166 - 167 - function SuggestedFollow({ 168 - profile: profileUnshadowed, 169 - }: { 170 - profile: AppBskyActorDefs.ProfileView 171 - }) { 172 - const {track} = useAnalytics() 173 - const pal = usePalette('default') 174 - const {_} = useLingui() 175 - const moderationOpts = useModerationOpts() 176 - const profile = useProfileShadow(profileUnshadowed) 177 - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 178 - profile, 179 - 'ProfileHeaderSuggestedFollows', 180 - ) 181 - 182 - const onPressFollow = React.useCallback(async () => { 183 - try { 184 - track('ProfileHeader:SuggestedFollowFollowed') 185 - await queueFollow() 186 - } catch (e: any) { 187 - if (e?.name !== 'AbortError') { 188 - Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 189 - } 190 - } 191 - }, [queueFollow, track, _]) 192 - 193 - const onPressUnfollow = React.useCallback(async () => { 194 - try { 195 - await queueUnfollow() 196 - } catch (e: any) { 197 - if (e?.name !== 'AbortError') { 198 - Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 199 - } 200 - } 201 - }, [queueUnfollow, _]) 202 - 203 - if (!moderationOpts) { 204 - return null 205 - } 206 - const moderation = moderateProfile(profile, moderationOpts) 207 - const following = profile.viewer?.following 208 - return ( 209 - <Link 210 - href={makeProfileLink(profile)} 211 - title={profile.handle} 212 - asAnchor 213 - anchorNoUnderline> 214 - <View 215 - style={[ 216 - styles.suggestedFollowCardOuter, 217 - { 218 - backgroundColor: pal.view.backgroundColor, 219 - }, 220 - ]}> 221 - <PreviewableUserAvatar 222 - size={60} 223 - profile={profile} 224 - avatar={profile.avatar} 225 - moderation={moderation.ui('avatar')} 226 - /> 227 - 228 - <View style={{width: '100%', paddingVertical: 12}}> 229 - <Text 230 - type="xs-medium" 231 - style={[pal.text, {textAlign: 'center'}]} 232 - numberOfLines={1}> 233 - {sanitizeDisplayName( 234 - profile.displayName || sanitizeHandle(profile.handle), 235 - moderation.ui('displayName'), 236 - )} 237 - </Text> 238 - <Text 239 - type="xs-medium" 240 - style={[pal.textLight, {textAlign: 'center'}]} 241 - numberOfLines={1}> 242 - {sanitizeHandle(profile.handle, '@')} 243 - </Text> 244 - </View> 245 - 246 - <Button 247 - label={following ? _(msg`Unfollow`) : _(msg`Follow`)} 248 - type="inverted" 249 - labelStyle={{textAlign: 'center'}} 250 - onPress={following ? onPressUnfollow : onPressFollow} 251 - /> 252 - </View> 253 - </Link> 254 - ) 255 - } 256 - 257 - const styles = StyleSheet.create({ 258 - suggestedFollowCardOuter: { 259 - marginHorizontal: INNER_PADDING / 2, 260 - paddingTop: 10, 261 - paddingBottom: 12, 262 - paddingHorizontal: 10, 263 - borderRadius: 8, 264 - width: 130, 265 - alignItems: 'center', 266 - overflow: 'hidden', 267 - flexShrink: 1, 268 - }, 269 - })