diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/Button.tsx | 71 | ||||
-rw-r--r-- | src/components/FeedCard.tsx | 41 | ||||
-rw-r--r-- | src/components/FeedInterstitials.tsx | 354 | ||||
-rw-r--r-- | src/components/ProfileCard.tsx | 87 | ||||
-rw-r--r-- | src/components/RichText.tsx | 26 | ||||
-rw-r--r-- | src/components/icons/Arrow.tsx | 4 | ||||
-rw-r--r-- | src/lib/statsig/events.ts | 5 | ||||
-rw-r--r-- | src/view/screens/Feeds.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/Storybook/Buttons.tsx | 2 |
9 files changed, 563 insertions, 28 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 54d9eaf3b..ed963026c 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -21,6 +21,7 @@ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' export type ButtonColor = | 'primary' | 'secondary' + | 'secondary_inverted' | 'negative' | 'gradient_sky' | 'gradient_midnight' @@ -235,6 +236,43 @@ export const Button = React.forwardRef<View, ButtonProps>( }) } } + } else if (color === 'secondary_inverted') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.contrast_900, + }) + hoverStyles.push({ + backgroundColor: t.palette.contrast_950, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.contrast_700, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.contrast_300, + }) + hoverStyles.push(t.atoms.bg_contrast_50) + } else { + baseStyles.push(a.border, { + borderColor: t.palette.contrast_200, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: t.palette.contrast_25, + }) + } + } } else if (color === 'negative') { if (variant === 'solid') { if (!disabled) { @@ -344,6 +382,7 @@ export const Button = React.forwardRef<View, ButtonProps>( const gradient = { primary: tokens.gradients.sky, secondary: tokens.gradients.sky, + secondary_inverted: tokens.gradients.sky, negative: tokens.gradients.sky, gradient_sky: tokens.gradients.sky, gradient_midnight: tokens.gradients.midnight, @@ -499,6 +538,38 @@ export function useSharedButtonTextStyles() { }) } } + } else if (color === 'secondary_inverted') { + if (variant === 'solid' || variant === 'gradient') { + if (!disabled) { + baseStyles.push({ + color: t.palette.white, + }) + } else { + baseStyles.push({ + color: t.palette.contrast_400, + }) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({ + color: t.palette.contrast_600, + }) + } else { + baseStyles.push({ + color: t.palette.contrast_300, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({ + color: t.palette.contrast_600, + }) + } else { + baseStyles.push({ + color: t.palette.contrast_300, + }) + } + } } else if (color === 'negative') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index b1200d9c4..5e50f3c48 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -30,7 +30,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Link as InternalLink, LinkProps} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' -import {RichText} from '#/components/RichText' +import {RichText, RichTextProps} from '#/components/RichText' import {Text} from '#/components/Typography' type Props = { @@ -70,22 +70,18 @@ export function Link({ }, [view, queryClient]) return ( - <InternalLink to={href} {...props}> + <InternalLink to={href} style={[a.flex_col]} {...props}> {children} </InternalLink> ) } export function Outer({children}: {children: React.ReactNode}) { - return <View style={[a.flex_1, a.gap_md]}>{children}</View> + return <View style={[a.w_full, a.gap_md]}>{children}</View> } export function Header({children}: {children: React.ReactNode}) { - return ( - <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}> - {children} - </View> - ) + return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> } export type AvatarProps = {src: string | undefined; size?: number} @@ -167,7 +163,10 @@ export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) { ) } -export function Description({description}: {description?: string}) { +export function Description({ + description, + ...rest +}: {description?: string} & Partial<RichTextProps>) { const rt = React.useMemo(() => { if (!description) return const rt = new RichTextApi({text: description || ''}) @@ -175,7 +174,29 @@ export function Description({description}: {description?: string}) { return rt }, [description]) if (!rt) return null - return <RichText value={rt} style={[a.leading_snug]} disableLinks /> + return <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} /> +} + +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 function Likes({count}: {count: number}) { diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx new file mode 100644 index 000000000..f1c4876a3 --- /dev/null +++ b/src/components/FeedInterstitials.tsx @@ -0,0 +1,354 @@ +import React from 'react' +import {View} from 'react-native' +import {ScrollView} from 'react-native-gesture-handler' +import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {NavigationProp} from '#/lib/routes/types' +import {logEvent} from '#/lib/statsig/statsig' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf' +import {Button} from '#/components/Button' +import * as FeedCard from '#/components/FeedCard' +import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' +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 * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' + +function CardOuter({ + children, + style, +}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { + const t = useTheme() + const {gtMobile} = useBreakpoints() + return ( + <View + style={[ + a.w_full, + a.p_lg, + a.rounded_md, + a.border, + t.atoms.bg, + t.atoms.border_contrast_low, + !gtMobile && { + width: 300, + }, + 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.Header> + + <View style={[a.py_xs]}> + <ProfileCard.NameAndHandlePlaceholder /> + </View> + + <ProfileCard.DescriptionPlaceholder /> + </CardOuter> + ) +} + +export function SuggestedFeedsCardPlaceholder() { + const t = useTheme() + return ( + <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}> + <FeedCard.Header> + <FeedCard.AvatarPlaceholder /> + <FeedCard.TitleAndBylinePlaceholder creator /> + </FeedCard.Header> + + <FeedCard.DescriptionPlaceholder /> + </CardOuter> + ) +} + +export function SuggestedFollows() { + const t = useTheme() + const {_} = useLingui() + const { + isLoading: isSuggestionsLoading, + data, + error, + } = useSuggestedFollowsQuery({limit: 6}) + const moderationOpts = useModerationOpts() + const navigation = useNavigation<NavigationProp>() + const {gtMobile} = useBreakpoints() + const isLoading = isSuggestionsLoading || !moderationOpts + const maxLength = gtMobile ? 4 : 6 + + const profiles: AppBskyActorDefs.ProfileViewBasic[] = [] + if (data) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + for (const page of data.pages) { + for (const actor of page.actors) { + if (!seen.has(actor.did)) { + seen.add(actor.did) + profiles.push(actor) + } + } + } + } + + const content = isLoading ? ( + Array(maxLength) + .fill(0) + .map((_, i) => ( + <View + key={i} + style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}> + <SuggestedFollowPlaceholder /> + </View> + )) + ) : error || !profiles.length ? null : ( + <> + {profiles.slice(0, maxLength).map(profile => ( + <ProfileCard.Link + key={profile.did} + did={profile.handle} + onPress={() => { + logEvent('feed:interstitial:profileCard:press', {}) + }} + style={[ + a.flex_1, + gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]), + ]}> + {({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} + logContext="FeedInterstitial" + color="secondary_inverted" + shape="round" + /> + </ProfileCard.Header> + <ProfileCard.Description profile={profile} /> + </ProfileCard.Outer> + </CardOuter> + )} + </ProfileCard.Link> + ))} + </> + ) + + return error ? null : ( + <View + style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> + <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> + <Text + style={[ + a.flex_1, + a.text_lg, + a.font_bold, + t.atoms.text_contrast_medium, + ]}> + <Trans>Suggested for you</Trans> + </Text> + <Person fill={t.atoms.text_contrast_low.color} /> + </View> + + {gtMobile ? ( + <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}> + <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> + {content} + </View> + + <View + style={[ + a.flex_row, + a.justify_end, + a.align_center, + a.pt_xs, + a.gap_md, + ]}> + <InlineLinkText 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> + ) : ( + <ScrollView horizontal showsHorizontalScrollIndicator={false}> + <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> + {content} + + <Button + label={_(msg`Browse more accounts on our 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 our explore page</Trans> + </Text> + + <Arrow size="xl" /> + </View> + </View> + </CardOuter> + </Button> + </View> + </ScrollView> + )} + </View> + ) +} + +export function SuggestedFeeds() { + const numFeedsToDisplay = 3 + const t = useTheme() + const {_} = useLingui() + const {data, isLoading, error} = useGetPopularFeedsQuery({ + limit: numFeedsToDisplay, + }) + const navigation = useNavigation<NavigationProp>() + const {gtMobile} = useBreakpoints() + + const feeds = React.useMemo(() => { + const items: AppBskyFeedDefs.GeneratorView[] = [] + + if (!data) return items + + for (const page of data.pages) { + for (const feed of page.feeds) { + items.push(feed) + } + } + + return items + }, [data]) + + const content = isLoading ? ( + Array(numFeedsToDisplay) + .fill(0) + .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />) + ) : error || !feeds ? null : ( + <> + {feeds.slice(0, numFeedsToDisplay).map(feed => ( + <FeedCard.Link + key={feed.uri} + view={feed} + onPress={() => { + logEvent('feed:interstitial:feedCard:press', {}) + }}> + {({hovered, pressed}) => ( + <CardOuter + style={[ + a.flex_1, + (hovered || pressed) && t.atoms.border_contrast_high, + ]}> + <FeedCard.Outer> + <FeedCard.Header> + <FeedCard.Avatar src={feed.avatar} /> + <FeedCard.TitleAndByline + title={feed.displayName} + creator={feed.creator} + /> + </FeedCard.Header> + <FeedCard.Description + description={feed.description} + numberOfLines={3} + /> + </FeedCard.Outer> + </CardOuter> + )} + </FeedCard.Link> + ))} + </> + ) + + return error ? null : ( + <View + style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> + <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> + <Text + style={[ + a.flex_1, + a.text_lg, + a.font_bold, + t.atoms.text_contrast_medium, + ]}> + <Trans>Some other feeds you might like</Trans> + </Text> + <Hashtag fill={t.atoms.text_contrast_low.color} /> + </View> + + {gtMobile ? ( + <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}> + {content} + + <View + style={[ + a.flex_row, + a.justify_end, + a.align_center, + a.pt_xs, + a.gap_md, + ]}> + <InlineLinkText 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> + ) : ( + <ScrollView horizontal showsHorizontalScrollIndicator={false}> + <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> + {content} + + <Button + label={_(msg`Browse more feeds on our explore page`)} + onPress={() => { + navigation.navigate('SearchTab') + }} + style={[a.flex_col]}> + <CardOuter style={[a.flex_1]}> + <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> + </View> + </ScrollView> + )} + </View> + ) +} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index a6ca7627b..77016d4fe 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -9,6 +9,7 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {LogEvents} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {sanitizeHandle} from 'lib/strings/handles' @@ -79,7 +80,7 @@ export function Outer({ }: { children: React.ReactElement | React.ReactElement[] }) { - return <View style={[a.flex_1, a.gap_xs]}>{children}</View> + return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View> } export function Header({ @@ -87,16 +88,23 @@ export function Header({ }: { 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, + style, + ...rest +}: {did: string} & Omit<LinkProps, 'to'>) { return ( <InternalLink to={{ screen: 'Profile', params: {name: did}, - }}> + }} + style={[a.flex_col, style]} + {...rest}> {children} </InternalLink> ) @@ -121,6 +129,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({ profile, moderationOpts, @@ -150,6 +174,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({ profile: profileUnshadowed, }: { @@ -183,9 +237,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 = { profile: AppBskyActorDefs.ProfileViewBasic - logContext: 'ProfileCard' | 'StarterPackProfilesList' + logContext: LogEvents['profile:follow']['logContext'] & + LogEvents['profile:unfollow']['logContext'] } & Partial<ButtonProps> export function FollowButton(props: FollowButtonProps) { diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 9ba44eabe..751177597 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -17,6 +17,19 @@ import {Text, TextProps} from '#/components/Typography' const WORD_WRAP = {wordWrap: 1} +export type RichTextProps = TextStyleProp & + Pick<TextProps, 'selectable'> & { + value: RichTextAPI | string + testID?: string + numberOfLines?: number + disableLinks?: boolean + enableTags?: boolean + authorHandle?: string + onLinkPress?: LinkProps['onPress'] + interactiveStyle?: TextStyle + emojiMultiplier?: number + } + export function RichText({ testID, value, @@ -29,18 +42,7 @@ export function RichText({ onLinkPress, interactiveStyle, emojiMultiplier = 1.85, -}: TextStyleProp & - Pick<TextProps, 'selectable'> & { - value: RichTextAPI | string - testID?: string - numberOfLines?: number - disableLinks?: boolean - enableTags?: boolean - authorHandle?: string - onLinkPress?: LinkProps['onPress'] - interactiveStyle?: TextStyle - emojiMultiplier?: number - }) { +}: RichTextProps) { const richText = React.useMemo( () => value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), diff --git a/src/components/icons/Arrow.tsx b/src/components/icons/Arrow.tsx index d6fb635e9..0d4bc9479 100644 --- a/src/components/icons/Arrow.tsx +++ b/src/components/icons/Arrow.tsx @@ -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', }) +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({ 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', }) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 81a2d55e2..4946fb7f2 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -153,6 +153,7 @@ export type LogEvents = { | 'ProfileHoverCard' | 'AvatarButton' | 'StarterPackProfilesList' + | 'FeedInterstitial' } 'profile:unfollow': { logContext: @@ -166,6 +167,7 @@ export type LogEvents = { | 'Chat' | 'AvatarButton' | 'StarterPackProfilesList' + | 'FeedInterstitial' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' @@ -201,6 +203,9 @@ export type LogEvents = { starterPack: string } + 'feed:interstitial:profileCard:press': {} + 'feed:interstitial:feedCard:press': {} + 'test:all:always': {} 'test:all:sometimes': {} 'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'} diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 82de30d5c..5a2d71087 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -642,6 +642,7 @@ function SavedFeed({ const t = useTheme() const commonStyle = [ + a.w_full, a.flex_1, a.px_lg, a.py_md, diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx index b532b0dd1..7cc3f60bf 100644 --- a/src/view/screens/Storybook/Buttons.tsx +++ b/src/view/screens/Storybook/Buttons.tsx @@ -20,7 +20,7 @@ export function Buttons() { <H1>Buttons</H1> <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}> - {['primary', 'secondary', 'negative'].map(color => ( + {['primary', 'secondary', 'secondary_inverted'].map(color => ( <View key={color} style={[a.gap_md, a.align_start]}> {['solid', 'outline', 'ghost'].map(variant => ( <React.Fragment key={variant}> |