Mostly there with follows

This commit is contained in:
Eric Bailey
2024-06-27 19:23:32 -05:00
parent 24d65b3ffb
commit 2bc24347a8
6 changed files with 192 additions and 174 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 284 B

+99 -154
View File
@@ -1,160 +1,60 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler' import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {isJustAMute} from 'lib/moderation' import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useFollowMethods} from '#/components/hooks/useFollowMethods' import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {useRichText} from '#/components/hooks/useRichText'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Refresh} from '#/components/icons/ArrowRotateCounterClockwise' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Refresh} from '#/components/icons/ArrowRotateCounterClockwise'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function SuggestedFollowCardSkeleton() { function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints()
return ( return (
<View <View
style={[ style={[
a.w_full,
a.p_lg, a.p_lg,
a.rounded_md, a.rounded_md,
a.gap_sm, a.border,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ !gtMobile && {
width: 300, width: 300,
}, },
{
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
style,
]}> ]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}> {children}
<View style={[a.rounded_full, t.atoms.bg, {height: 40, width: 40}]} />
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.flex_1,
]}>
<View style={[a.gap_xs, a.flex_1]}>
<View
style={[a.rounded_xs, t.atoms.bg, {height: 16, width: 200}]}
/>
<View
style={[a.rounded_xs, t.atoms.bg, {height: 12, width: 100}]}
/>
</View>
</View>
</View>
<View style={[a.gap_xs]}>
<View style={[a.rounded_xs, a.w_full, t.atoms.bg, {height: 12}]} />
<View style={[a.rounded_xs, a.w_full, t.atoms.bg, {height: 12}]} />
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg, {height: 12, width: 100}]}
/>
</View>
</View> </View>
) )
} }
export function SuggestedFollowCard({ export function SuggestedFollowCardSkeleton() {
profile: profileUnshadowed,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const moderationOpts = useModerationOpts()
const {follow, unfollow} = useFollowMethods({
profile,
logContext: 'FeedSuggestedFollowCard',
})
// @ts-ignore TODO why isn't this type correct
const [descriptionRT] = useRichText(profileUnshadowed?.description ?? '')
if (!moderationOpts) return null
const moderation = moderateProfile(profile, moderationOpts)
const modui = moderation.ui('profileList')
if (modui.filter && !isJustAMute(modui)) return null
return ( return (
<View <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
style={[ <ProfileCard.Header>
a.p_lg, <ProfileCard.AvatarPlaceholder />
a.rounded_md, <ProfileCard.NameAndHandlePlaceholder />
a.gap_sm, </ProfileCard.Header>
t.atoms.bg_contrast_25,
{
width: 300,
},
]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<PreviewableUserAvatar
size={40}
profile={profile}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
<View <ProfileCard.DescriptionPlaceholder />
style={[ </CardOuter>
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.flex_1,
]}>
<View style={[a.gap_2xs, a.flex_1]}>
<Text
style={[a.text_md, a.font_bold, a.leading_tight, a.flex_1]}
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.flex_1]}
numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
<Button
label={
profile.viewer?.following ? _(msg`Following`) : _(msg`Follow`)
}
size="small"
shape="round"
variant="solid"
color="primary"
onPress={profile.viewer?.following ? unfollow : follow}>
{profile.viewer?.following ? (
<ButtonIcon icon={Check} />
) : (
<ButtonIcon icon={Plus} />
)}
</Button>
</View>
</View>
<Text numberOfLines={3}>
<RichText value={descriptionRT} style={[t.atoms.text_contrast_high]} />
</Text>
</View>
) )
} }
@@ -162,17 +62,7 @@ export function ErrorState({retry}: {retry: () => void}) {
const t = useTheme() const t = useTheme()
return ( return (
<> <>
<View <CardOuter>
style={[
a.align_start,
a.p_lg,
a.rounded_md,
a.gap_md,
t.atoms.bg_contrast_25,
{
width: 300,
},
]}>
<CircleInfo size="lg" fill={t.palette.negative_400} /> <CircleInfo size="lg" fill={t.palette.negative_400} />
<Text style={[a.font_bold]}>Whoops, something went wrong :(</Text> <Text style={[a.font_bold]}>Whoops, something went wrong :(</Text>
<Button <Button
@@ -184,7 +74,7 @@ export function ErrorState({retry}: {retry: () => void}) {
<ButtonText>Retry</ButtonText> <ButtonText>Retry</ButtonText>
<ButtonIcon icon={Refresh} position="right" /> <ButtonIcon icon={Refresh} position="right" />
</Button> </Button>
</View> </CardOuter>
<SuggestedFollowCardSkeleton /> <SuggestedFollowCardSkeleton />
<SuggestedFollowCardSkeleton /> <SuggestedFollowCardSkeleton />
@@ -193,12 +83,19 @@ export function ErrorState({retry}: {retry: () => void}) {
} }
export function FeedSuggestedFollowsCards() { export function FeedSuggestedFollowsCards() {
const t = useTheme()
const {_} = useLingui()
const { const {
isLoading, isLoading: isSuggestionsLoading,
data: suggestions, data: suggestions,
error, error,
refetch, refetch,
} = useSuggestedFollowsQuery() } = useSuggestedFollowsQuery({limit: 6})
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts
const maxLength = gtMobile ? 3 : 6
const profiles: AppBskyActorDefs.ProfileViewBasic[] = [] const profiles: AppBskyActorDefs.ProfileViewBasic[] = []
if (suggestions) { if (suggestions) {
@@ -215,21 +112,69 @@ export function FeedSuggestedFollowsCards() {
} }
} }
return ( const content = isLoading ? (
Array(3)
.fill(0)
.map((_, i) => <SuggestedFollowCardSkeleton key={i} />)
) : error || !profiles.length ? (
<ErrorState retry={refetch} />
) : (
<>
{profiles.slice(0, maxLength).map(profile => (
<ProfileCard.Link key={profile.did} did={profile.handle}>
<CardOuter style={[a.flex_1]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
logContext="FeedSuggestedFollowsCard"
shape={gtMobile ? undefined : 'round'}
variant="outline"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</CardOuter>
</ProfileCard.Link>
))}
</>
)
return gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
{content}
</View>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{isLoading ? ( {content}
Array(3)
.fill(0)
.map((_, i) => <SuggestedFollowCardSkeleton key={i} />)
) : error || !profiles.length ? (
<ErrorState retry={refetch} />
) : (
profiles.map((profile, i) => (
<SuggestedFollowCard key={i} profile={profile} />
))
)}
</View> </View>
<Button
label={_(msg`Browse more accounts on our explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, t.atoms.bg_contrast_25, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on our explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</ScrollView> </ScrollView>
) )
} }
+74 -3
View File
@@ -9,6 +9,7 @@ import {
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
@@ -87,7 +88,7 @@ export function Header({
}: { }: {
children: React.ReactElement | React.ReactElement[] children: React.ReactElement | React.ReactElement[]
}) { }) {
return <View style={[a.flex_row, a.gap_sm]}>{children}</View> return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
} }
export function Link({did, children}: {did: string} & Omit<LinkProps, 'to'>) { export function Link({did, children}: {did: string} & Omit<LinkProps, 'to'>) {
@@ -96,7 +97,8 @@ export function Link({did, children}: {did: string} & Omit<LinkProps, 'to'>) {
to={{ to={{
screen: 'Profile', screen: 'Profile',
params: {name: did}, params: {name: did},
}}> }}
style={[a.flex_col]}>
{children} {children}
</InternalLink> </InternalLink>
) )
@@ -121,6 +123,22 @@ export function Avatar({
) )
} }
export function AvatarPlaceholder() {
const t = useTheme()
return (
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_50,
{
width: 42,
height: 42,
},
]}
/>
)
}
export function NameAndHandle({ export function NameAndHandle({
profile, profile,
moderationOpts, moderationOpts,
@@ -150,6 +168,36 @@ export function NameAndHandle({
) )
} }
export function NameAndHandlePlaceholder() {
const t = useTheme()
return (
<View style={[a.flex_1, a.gap_xs]}>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
width: '60%',
height: 14,
},
]}
/>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
width: '40%',
height: 10,
},
]}
/>
</View>
)
}
export function Description({ export function Description({
profile: profileUnshadowed, profile: profileUnshadowed,
}: { }: {
@@ -183,9 +231,32 @@ export function Description({
) )
} }
export function DescriptionPlaceholder() {
const t = useTheme()
return (
<View style={[a.gap_xs]}>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: 100},
]}
/>
</View>
)
}
export type FollowButtonProps = { export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
logContext: 'ProfileCard' | 'StarterPackProfilesList' logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
} & Partial<ButtonProps> } & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) { export function FollowButton(props: FollowButtonProps) {
+4
View File
@@ -8,6 +8,10 @@ export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z', path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
}) })
export const ArrowRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z',
})
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z', path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
}) })
+2
View File
@@ -147,6 +147,7 @@ export type LogEvents = {
| 'ProfileHoverCard' | 'ProfileHoverCard'
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedSuggestedFollowsCard'
} }
'profile:unfollow': { 'profile:unfollow': {
logContext: logContext:
@@ -160,6 +161,7 @@ export type LogEvents = {
| 'Chat' | 'Chat'
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedSuggestedFollowsCard'
} }
'chat:create': { 'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
+12 -17
View File
@@ -3,14 +3,13 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {emitSoftReset} from '#/state/events'
import {CenteredView} from '#/view/com/util/Views' import {CenteredView} from '#/view/com/util/Views'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import {FeedSuggestedFollowsCards} from '#/components/FeedSuggestedFollows' import {FeedSuggestedFollowsCards} from '#/components/FeedSuggestedFollows'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Refresh} from '#/components/icons/ArrowRotateCounterClockwise' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Refresh} from '#/components/icons/ArrowRotateCounterClockwise'
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function EmptyTimeline() { export function EmptyTimeline() {
@@ -19,22 +18,17 @@ export function EmptyTimeline() {
return ( return (
<CenteredView sideBorders style={[a.h_full_vh]}> <CenteredView sideBorders style={[a.h_full_vh]}>
<View style={[a.px_lg, a.pt_3xl]}> <View style={[a.px_lg, a.pt_3xl]}>
<IconCircle icon={FilterTimeline} style={[a.mb_2xl]} /> <Text style={[a.text_2xl, a.font_bold, a.mb_md]}>
<Trans>Your Following feed is empty :(</Trans>
<Text style={[a.text_xl, a.font_bold, a.mb_sm]}>
<Trans>Welcome to your timeline</Trans>
</Text> </Text>
<Text style={[a.mb_sm, a.leading_snug]}> <Text style={[a.text_md, a.mb_sm, a.leading_snug]}>
<Trans> <Trans>
Your timeline is where you can see posts from people you follow. Follow some users to see what's happening. Their content will appear
It's always chronologically sorted, so you'll never miss a post. here in chronological order.
</Trans> </Trans>
</Text> </Text>
<Text style={[a.pt_lg, a.font_bold, a.mb_sm, a.leading_snug]}>
<Trans>Follow some people to get started!</Trans>
</Text>
</View> </View>
<View style={[a.flex_row, a.align_start]}> <View style={[a.flex_row, a.align_start]}>
<FeedSuggestedFollowsCards /> <FeedSuggestedFollowsCards />
</View> </View>
@@ -54,12 +48,13 @@ export function EmptyTimeline() {
</Text> </Text>
<Button <Button
label={_(msg`Click to view your timeline`)} label={_(msg`Click to refresh your following feed`)}
size="medium" size="small"
variant="solid" variant="solid"
color="primary"> color="secondary"
onPress={() => emitSoftReset()}>
<ButtonText> <ButtonText>
<Trans>View your timeline</Trans> <Trans>Refresh your feed</Trans>
</ButtonText> </ButtonText>
<ButtonIcon icon={Refresh} position="right" /> <ButtonIcon icon={Refresh} position="right" />
</Button> </Button>