ALF suggested follows in profile header (#4828)

* Refactor ProfileHeaderSuggestedFollows

* Load fresh data every time

* Oops, missed a file

* Update ProfileCard.Link usage, tweak copy
zio/stable
Eric Bailey 2024-08-08 09:19:51 -05:00 committed by GitHub
parent af5262682e
commit 1e3b2d6f42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 155 additions and 229 deletions

View File

@ -159,6 +159,7 @@ export type LogEvents = {
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedInterstitial' | 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
} }
'profile:unfollow': { 'profile:unfollow': {
logContext: logContext:
@ -173,6 +174,7 @@ export type LogEvents = {
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedInterstitial' | 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
} }
'chat:create': { 'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@ -211,6 +213,8 @@ export type LogEvents = {
'feed:interstitial:profileCard:press': {} 'feed:interstitial:profileCard:press': {}
'feed:interstitial:feedCard:press': {} 'feed:interstitial:feedCard:press': {}
'profile:header:suggestedFollowsCard:press': {}
'debug:followingPrefs': { 'debug:followingPrefs': {
followingShowRepliesFromPref: 'all' | 'following' | 'off' followingShowRepliesFromPref: 'all' | 'following' | 'off'
followingRepliesMinLikePref: number followingRepliesMinLikePref: number

View File

@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
export function useSuggestedFollowsByActorQuery({did}: {did: string}) { export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const agent = useAgent() const agent = useAgent()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
gcTime: 0,
queryKey: suggestedFollowsByActorQueryKey(did), queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => { queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({

View File

@ -1,32 +1,60 @@
import React from 'react' import React from 'react'
import {Pressable, ScrollView, StyleSheet, View} from 'react-native' import {ScrollView, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {logEvent} from '#/lib/statsig/statsig'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {Button} from 'view/com/util/forms/Button' import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Link} from 'view/com/util/Link' import {Button, ButtonIcon} from '#/components/Button'
import {Text} from 'view/com/util/text/Text' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '../util/Toast' import {Text} from '#/components/Typography'
const OUTER_PADDING = 10 const OUTER_PADDING = a.p_md.padding
const INNER_PADDING = 14 const INNER_PADDING = a.p_lg.padding
const TOTAL_HEIGHT = 250 const TOTAL_HEIGHT = 232
const MOBILE_CARD_WIDTH = 300
function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.p_lg,
a.rounded_md,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
{
width: MOBILE_CARD_WIDTH,
},
style,
]}>
{children}
</View>
)
}
export function SuggestedFollowPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
<ProfileCard.DescriptionPlaceholder />
</CardOuter>
)
}
export function ProfileHeaderSuggestedFollows({ export function ProfileHeaderSuggestedFollows({
actorDid, actorDid,
@ -35,47 +63,55 @@ export function ProfileHeaderSuggestedFollows({
actorDid: string actorDid: string
requestDismiss: () => void requestDismiss: () => void
}) { }) {
const pal = usePalette('default') const t = useTheme()
const {isLoading, data} = useSuggestedFollowsByActorQuery({ const {_} = useLingui()
did: actorDid, const {isLoading: isSuggestionsLoading, data} =
}) useSuggestedFollowsByActorQuery({
did: actorDid,
})
const moderationOpts = useModerationOpts()
const isLoading = isSuggestionsLoading || !moderationOpts
return ( return (
<View <View
style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}} style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}}
pointerEvents="box-none"> pointerEvents="box-none">
<View <View
pointerEvents="box-none" pointerEvents="box-none"
style={{ style={[
backgroundColor: pal.viewLight.backgroundColor, t.atoms.bg_contrast_25,
height: '100%', {
paddingTop: INNER_PADDING / 2, height: '100%',
}}> paddingTop: INNER_PADDING / 2,
},
]}>
<View <View
pointerEvents="box-none" pointerEvents="box-none"
style={{ style={[
flexDirection: 'row', a.flex_row,
justifyContent: 'space-between', a.justify_between,
alignItems: 'center', a.align_center,
paddingTop: 4, a.pt_xs,
paddingBottom: INNER_PADDING / 2, {
paddingLeft: INNER_PADDING, paddingBottom: INNER_PADDING / 2,
paddingRight: INNER_PADDING / 2, paddingLeft: INNER_PADDING,
}}> paddingRight: INNER_PADDING / 2,
<Text type="sm-bold" style={[pal.textLight]}> },
<Trans>Suggested for you</Trans> ]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Similar accounts</Trans>
</Text> </Text>
<Pressable <Button
accessibilityRole="button"
onPress={requestDismiss} onPress={requestDismiss}
hitSlop={10} hitSlop={10}
style={{padding: INNER_PADDING / 2}}> label={_(msg`Dismiss`)}
<FontAwesomeIcon size="xsmall"
icon="x" variant="ghost"
size={12} color="secondary"
style={pal.textLight as FontAwesomeIconStyle} shape="round">
/> <ButtonIcon icon={X} size="sm" />
</Pressable> </Button>
</View> </View>
<ScrollView <ScrollView
@ -83,187 +119,72 @@ export function ProfileHeaderSuggestedFollows({
showsHorizontalScrollIndicator={isWeb} showsHorizontalScrollIndicator={isWeb}
persistentScrollbar={true} persistentScrollbar={true}
scrollIndicatorInsets={{bottom: 0}} scrollIndicatorInsets={{bottom: 0}}
scrollEnabled={true} snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap}
contentContainerStyle={{ decelerationRate="fast">
alignItems: 'flex-start', <View
paddingLeft: INNER_PADDING / 2, style={[
paddingBottom: INNER_PADDING, a.flex_row,
}}> a.gap_sm,
{isLoading ? ( {
<> paddingHorizontal: INNER_PADDING,
<SuggestedFollowSkeleton /> paddingBottom: INNER_PADDING,
<SuggestedFollowSkeleton /> },
<SuggestedFollowSkeleton /> ]}>
<SuggestedFollowSkeleton /> {isLoading ? (
<SuggestedFollowSkeleton /> <>
<SuggestedFollowSkeleton /> <SuggestedFollowPlaceholder />
</> <SuggestedFollowPlaceholder />
) : data ? ( <SuggestedFollowPlaceholder />
data.suggestions <SuggestedFollowPlaceholder />
.filter(s => (s.associated?.labeler ? false : true)) <SuggestedFollowPlaceholder />
.map(profile => ( </>
<SuggestedFollow key={profile.did} profile={profile} /> ) : data ? (
)) data.suggestions
) : ( .filter(s => (s.associated?.labeler ? false : true))
<View /> .map(profile => (
)} <ProfileCard.Link
key={profile.did}
profile={profile}
onPress={() => {
logEvent('profile:header:suggestedFollowsCard:press', {})
}}
style={[a.flex_1]}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="ProfileHeaderSuggestedFollows"
color="secondary_inverted"
shape="round"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
))
) : (
<View />
)}
</View>
</ScrollView> </ScrollView>
</View> </View>
</View> </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>
)
}
function SuggestedFollow({
profile: profileUnshadowed,
}: {
profile: AppBskyActorDefs.ProfileView
}) {
const {track} = useAnalytics()
const pal = usePalette('default')
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const profile = useProfileShadow(profileUnshadowed)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeaderSuggestedFollows',
)
const onPressFollow = React.useCallback(async () => {
try {
track('ProfileHeader:SuggestedFollowFollowed')
await queueFollow()
} catch (e: any) {
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
}
}
}, [queueFollow, track, _])
const onPressUnfollow = React.useCallback(async () => {
try {
await queueUnfollow()
} catch (e: any) {
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
}
}
}, [queueUnfollow, _])
if (!moderationOpts) {
return null
}
const moderation = moderateProfile(profile, moderationOpts)
const following = profile.viewer?.following
return (
<Link
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline>
<View
style={[
styles.suggestedFollowCardOuter,
{
backgroundColor: pal.view.backgroundColor,
},
]}>
<PreviewableUserAvatar
size={60}
profile={profile}
avatar={profile.avatar}
moderation={moderation.ui('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.ui('displayName'),
)}
</Text>
<Text
type="xs-medium"
style={[pal.textLight, {textAlign: 'center'}]}
numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
<Button
label={following ? _(msg`Unfollow`) : _(msg`Follow`)}
type="inverted"
labelStyle={{textAlign: 'center'}}
onPress={following ? onPressUnfollow : onPressFollow}
/>
</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,
},
})