ALF suggested follows in profile header (#4828)
* Refactor ProfileHeaderSuggestedFollows * Load fresh data every time * Oops, missed a file * Update ProfileCard.Link usage, tweak copyzio/stable
parent
af5262682e
commit
1e3b2d6f42
|
@ -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
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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()
|
||||||
|
const {isLoading: isSuggestionsLoading, data} =
|
||||||
|
useSuggestedFollowsByActorQuery({
|
||||||
did: actorDid,
|
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%',
|
height: '100%',
|
||||||
paddingTop: INNER_PADDING / 2,
|
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,
|
paddingBottom: INNER_PADDING / 2,
|
||||||
paddingLeft: INNER_PADDING,
|
paddingLeft: INNER_PADDING,
|
||||||
paddingRight: INNER_PADDING / 2,
|
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={[
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
{
|
||||||
|
paddingHorizontal: INNER_PADDING,
|
||||||
paddingBottom: INNER_PADDING,
|
paddingBottom: INNER_PADDING,
|
||||||
}}>
|
},
|
||||||
|
]}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<SuggestedFollowSkeleton />
|
<SuggestedFollowPlaceholder />
|
||||||
<SuggestedFollowSkeleton />
|
<SuggestedFollowPlaceholder />
|
||||||
<SuggestedFollowSkeleton />
|
<SuggestedFollowPlaceholder />
|
||||||
<SuggestedFollowSkeleton />
|
<SuggestedFollowPlaceholder />
|
||||||
<SuggestedFollowSkeleton />
|
<SuggestedFollowPlaceholder />
|
||||||
<SuggestedFollowSkeleton />
|
|
||||||
</>
|
</>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
data.suggestions
|
data.suggestions
|
||||||
.filter(s => (s.associated?.labeler ? false : true))
|
.filter(s => (s.associated?.labeler ? false : true))
|
||||||
.map(profile => (
|
.map(profile => (
|
||||||
<SuggestedFollow key={profile.did} profile={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 />
|
||||||
)}
|
)}
|
||||||
|
</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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
Loading…
Reference in New Issue