Merge pull request #8733 from internet-development/caidan/app-1330-new-design-for-suggested-for-you-interstitial

feat: new design for "Suggested for you" interstitial
This commit is contained in:
jim
2025-07-29 17:42:30 -07:00
committed by GitHub
2 changed files with 162 additions and 86 deletions
+126 -77
View File
@@ -25,18 +25,17 @@ import {
type ViewStyleProp, type ViewStyleProp,
web, web,
} from '#/alf' } from '#/alf'
import {Button} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard' import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {ProgressGuideList} from './ProgressGuide/List' import {ProgressGuideList} from './ProgressGuide/List'
const MOBILE_CARD_WIDTH = 300 const MOBILE_CARD_WIDTH = 165
function CardOuter({ function CardOuter({
children, children,
@@ -48,8 +47,8 @@ function CardOuter({
<View <View
style={[ style={[
a.w_full, a.w_full,
a.p_lg, a.p_md,
a.rounded_md, a.rounded_lg,
a.border, a.border,
t.atoms.bg, t.atoms.bg,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
@@ -65,14 +64,30 @@ function CardOuter({
export function SuggestedFollowPlaceholder() { export function SuggestedFollowPlaceholder() {
const t = useTheme() const t = useTheme()
return (
<CardOuter style={[a.gap_md, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
<ProfileCard.DescriptionPlaceholder numberOfLines={2} /> return (
<CardOuter
style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
<ProfileCard.Outer>
<View
style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
<ProfileCard.AvatarPlaceholder size={88} />
<ProfileCard.NamePlaceholder />
<View style={[a.w_full]}>
<ProfileCard.DescriptionPlaceholder numberOfLines={2} />
</View>
</View>
<Button
label=""
size="small"
variant="solid"
color="secondary"
disabled
style={[a.w_full, a.rounded_sm]}>
<ButtonText>Follow</ButtonText>
</Button>
</ProfileCard.Outer>
</CardOuter> </CardOuter>
) )
} }
@@ -243,10 +258,9 @@ export function ProfileGrid({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts const isLoading = isSuggestionsLoading || !moderationOpts
const maxLength = gtMobile ? 4 : 6 const maxLength = gtMobile ? 3 : 6
const content = isLoading ? ( const content = isLoading ? (
Array(maxLength) Array(maxLength)
@@ -254,7 +268,14 @@ export function ProfileGrid({
.map((_, i) => ( .map((_, i) => (
<View <View
key={i} key={i}
style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}> style={[
gtMobile &&
web([
a.flex_0,
a.flex_grow,
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
]),
]}>
<SuggestedFollowPlaceholder /> <SuggestedFollowPlaceholder />
</View> </View>
)) ))
@@ -276,44 +297,69 @@ export function ProfileGrid({
}} }}
style={[ style={[
a.flex_1, a.flex_1,
gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]), gtMobile &&
web([
a.flex_0,
a.flex_grow,
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
]),
]}> ]}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
<CardOuter <CardOuter
style={[ style={[
a.flex_1, a.flex_1,
t.atoms.shadow_sm,
(hovered || pressed) && t.atoms.border_contrast_high, (hovered || pressed) && t.atoms.border_contrast_high,
]}> ]}>
<ProfileCard.Outer> <ProfileCard.Outer>
<ProfileCard.Header> <View
style={[
a.flex_col,
a.align_center,
a.gap_sm,
a.pb_sm,
a.mb_auto,
]}>
<ProfileCard.Avatar <ProfileCard.Avatar
profile={profile} profile={profile}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
size={88}
/> />
<ProfileCard.NameAndHandle <View style={[a.flex_col, a.align_center, a.max_w_full]}>
profile={profile} <ProfileCard.Name
moderationOpts={moderationOpts} profile={profile}
/> moderationOpts={moderationOpts}
<ProfileCard.FollowButton />
profile={profile} <ProfileCard.Description
moderationOpts={moderationOpts} profile={profile}
logContext="FeedInterstitial" numberOfLines={2}
shape="round" style={[
colorInverted t.atoms.text_contrast_medium,
onFollow={() => { a.text_center,
logEvent('suggestedUser:follow', { a.text_xs,
logContext: ]}
viewContext === 'feed' />
? 'InterstitialDiscover' </View>
: 'InterstitialProfile', </View>
location: 'Card',
recId, <ProfileCard.FollowButton
position: index, profile={profile}
}) moderationOpts={moderationOpts}
}} logContext="FeedInterstitial"
/> withIcon={false}
</ProfileCard.Header> style={[a.rounded_sm]}
<ProfileCard.Description profile={profile} numberOfLines={2} /> onFollow={() => {
logEvent('suggestedUser:follow', {
logContext:
viewContext === 'feed'
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
position: index,
})
}}
/>
</ProfileCard.Outer> </ProfileCard.Outer>
</CardOuter> </CardOuter>
)} )}
@@ -333,36 +379,30 @@ export function ProfileGrid({
<View <View
style={[ style={[
a.p_lg, a.p_lg,
a.pb_xs, a.py_md,
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.justify_between, a.justify_between,
]}> ]}>
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
{viewContext === 'profile' ? ( {viewContext === 'profile' ? (
<Trans>Similar accounts</Trans> <Trans>Similar accounts</Trans>
) : ( ) : (
<Trans>Suggested for you</Trans> <Trans>Suggested for you</Trans>
)} )}
</Text> </Text>
<Person fill={t.atoms.text_contrast_low.color} size="sm" /> <InlineLinkText
label={_(msg`See more suggested profiles on the Explore page`)}
to="/search">
<Trans>See more</Trans>
</InlineLinkText>
</View> </View>
{gtMobile ? ( {gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_sm, a.pb_lg, a.gap_md]}> <View style={[a.px_lg, a.pb_lg]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_sm]}> <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content} {content}
</View> </View>
<View style={[a.flex_row, a.justify_end, a.align_center, a.gap_md]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</View> </View>
) : ( ) : (
<BlockDrawerGesture> <BlockDrawerGesture>
@@ -371,29 +411,12 @@ export function ProfileGrid({
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast"> decelerationRate="fast"
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}> style={[a.overflow_visible]}>
<View style={[a.px_lg, a.pb_lg, a.flex_row, a.gap_md]}>
{content} {content}
<Button <SeeMoreSuggestedProfilesCard />
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {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 the Explore page
</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View> </View>
</ScrollView> </ScrollView>
</View> </View>
@@ -403,6 +426,32 @@ export function ProfileGrid({
) )
} }
function SeeMoreSuggestedProfilesCard() {
const navigation = useNavigation<NavigationProp>()
const t = useTheme()
const {_} = useLingui()
return (
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, t.atoms.shadow_sm]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_col, a.align_center, a.gap_md]}>
<Text style={[a.leading_snug, a.text_center]}>
<Trans>See more accounts you might like</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
)
}
export function SuggestedFeeds() { export function SuggestedFeeds() {
const numFeedsToDisplay = 3 const numFeedsToDisplay = 3
const t = useTheme() const t = useTheme()
+36 -9
View File
@@ -20,7 +20,13 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, platform, useTheme} from '#/alf' import {
atoms as a,
platform,
type TextStyleProp,
useTheme,
type ViewStyleProp,
} from '#/alf'
import { import {
Button, Button,
ButtonIcon, ButtonIcon,
@@ -136,12 +142,14 @@ export function Avatar({
onPress, onPress,
disabledPreview, disabledPreview,
liveOverride, liveOverride,
size = 40,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
onPress?: () => void onPress?: () => void
disabledPreview?: boolean disabledPreview?: boolean
liveOverride?: boolean liveOverride?: boolean
size?: number
}) { }) {
const moderation = moderateProfile(profile, moderationOpts) const moderation = moderateProfile(profile, moderationOpts)
@@ -149,7 +157,7 @@ export function Avatar({
return disabledPreview ? ( return disabledPreview ? (
<UserAvatar <UserAvatar
size={40} size={size}
avatar={profile.avatar} avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'} type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
@@ -157,7 +165,7 @@ export function Avatar({
/> />
) : ( ) : (
<PreviewableUserAvatar <PreviewableUserAvatar
size={40} size={size}
profile={profile} profile={profile}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
onBeforePress={onPress} onBeforePress={onPress}
@@ -166,7 +174,7 @@ export function Avatar({
) )
} }
export function AvatarPlaceholder() { export function AvatarPlaceholder({size = 40}: {size?: number}) {
const t = useTheme() const t = useTheme()
return ( return (
<View <View
@@ -174,8 +182,8 @@ export function AvatarPlaceholder() {
a.rounded_full, a.rounded_full,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
width: 40, width: size,
height: 40, height: size,
}, },
]} ]}
/> />
@@ -274,7 +282,7 @@ export function Name({
) )
const verification = useSimpleVerificationState({profile}) const verification = useSimpleVerificationState({profile})
return ( return (
<View style={[a.flex_row, a.align_center]}> <View style={[a.flex_row, a.align_center, a.max_w_full]}>
<Text <Text
emoji emoji
style={[ style={[
@@ -343,13 +351,32 @@ export function NameAndHandlePlaceholder() {
) )
} }
export function NamePlaceholder({style}: ViewStyleProp) {
const t = useTheme()
return (
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
{
width: '60%',
height: 14,
},
style,
]}
/>
)
}
export function Description({ export function Description({
profile: profileUnshadowed, profile: profileUnshadowed,
numberOfLines = 3, numberOfLines = 3,
style,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
numberOfLines?: number numberOfLines?: number
}) { } & TextStyleProp) {
const profile = useProfileShadow(profileUnshadowed) const profile = useProfileShadow(profileUnshadowed)
const rt = useMemo(() => { const rt = useMemo(() => {
if (!('description' in profile)) return if (!('description' in profile)) return
@@ -369,7 +396,7 @@ export function Description({
<View style={[a.pt_xs]}> <View style={[a.pt_xs]}>
<RichText <RichText
value={rt} value={rt}
style={[a.leading_snug]} style={[a.leading_snug, style]}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
disableLinks disableLinks
/> />