diff --git a/assets/icons/arrowRight_stroke2_corner0_rounded.svg b/assets/icons/arrowRight_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..dbbbbc2c --- /dev/null +++ b/assets/icons/arrowRight_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 54d9eaf3..ed963026 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' @@ -217,6 +218,43 @@ export const Button = React.forwardRef( 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 === '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, @@ -344,6 +382,7 @@ export const Button = React.forwardRef( 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 b1200d9c..5e50f3c4 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 ( - + {children} ) } export function Outer({children}: {children: React.ReactNode}) { - return {children} + return {children} } export function Header({children}: {children: React.ReactNode}) { - return ( - - {children} - - ) + return {children} } 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) { 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 + return +} + +export function DescriptionPlaceholder() { + const t = useTheme() + return ( + + + + + + ) } export function Likes({count}: {count: number}) { diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx new file mode 100644 index 00000000..f1c4876a --- /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 ( + + {children} + + ) +} + +export function SuggestedFollowPlaceholder() { + const t = useTheme() + return ( + + + + + + + + + + + + ) +} + +export function SuggestedFeedsCardPlaceholder() { + const t = useTheme() + return ( + + + + + + + + + ) +} + +export function SuggestedFollows() { + const t = useTheme() + const {_} = useLingui() + const { + isLoading: isSuggestionsLoading, + data, + error, + } = useSuggestedFollowsQuery({limit: 6}) + const moderationOpts = useModerationOpts() + const navigation = useNavigation() + 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) => ( + + + + )) + ) : error || !profiles.length ? null : ( + <> + {profiles.slice(0, maxLength).map(profile => ( + { + logEvent('feed:interstitial:profileCard:press', {}) + }} + style={[ + a.flex_1, + gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]), + ]}> + {({hovered, pressed}) => ( + + + + + + + + + + + )} + + ))} + + ) + + return error ? null : ( + + + + Suggested for you + + + + + {gtMobile ? ( + + + {content} + + + + + Browse more suggestions + + + + + ) : ( + + + {content} + + + + + )} + + ) +} + +export function SuggestedFeeds() { + const numFeedsToDisplay = 3 + const t = useTheme() + const {_} = useLingui() + const {data, isLoading, error} = useGetPopularFeedsQuery({ + limit: numFeedsToDisplay, + }) + const navigation = useNavigation() + 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) => ) + ) : error || !feeds ? null : ( + <> + {feeds.slice(0, numFeedsToDisplay).map(feed => ( + { + logEvent('feed:interstitial:feedCard:press', {}) + }}> + {({hovered, pressed}) => ( + + + + + + + + + + )} + + ))} + + ) + + return error ? null : ( + + + + Some other feeds you might like + + + + + {gtMobile ? ( + + {content} + + + + Browse more suggestions + + + + + ) : ( + + + {content} + + + + + )} + + ) +} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index a6ca7627..77016d4f 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 {children} + return {children} } export function Header({ @@ -87,16 +88,23 @@ export function Header({ }: { children: React.ReactElement | React.ReactElement[] }) { - return {children} + return {children} } -export function Link({did, children}: {did: string} & Omit) { +export function Link({ + did, + children, + style, + ...rest +}: {did: string} & Omit) { return ( + }} + style={[a.flex_col, style]} + {...rest}> {children} ) @@ -121,6 +129,22 @@ export function Avatar({ ) } +export function AvatarPlaceholder() { + const t = useTheme() + return ( + + ) +} + export function NameAndHandle({ profile, moderationOpts, @@ -150,6 +174,36 @@ export function NameAndHandle({ ) } +export function NameAndHandlePlaceholder() { + const t = useTheme() + + return ( + + + + + + ) +} + export function Description({ profile: profileUnshadowed, }: { @@ -183,9 +237,32 @@ export function Description({ ) } +export function DescriptionPlaceholder() { + const t = useTheme() + return ( + + + + + + ) +} + export type FollowButtonProps = { profile: AppBskyActorDefs.ProfileViewBasic - logContext: 'ProfileCard' | 'StarterPackProfilesList' + logContext: LogEvents['profile:follow']['logContext'] & + LogEvents['profile:unfollow']['logContext'] } & Partial export function FollowButton(props: FollowButtonProps) { diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 9ba44eab..75117759 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 & { + 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 & { - 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 d6fb635e..0d4bc947 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 81a2d55e..4946fb7f 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 82de30d5..5a2d7108 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 b532b0dd..7cc3f60b 100644 --- a/src/view/screens/Storybook/Buttons.tsx +++ b/src/view/screens/Storybook/Buttons.tsx @@ -20,7 +20,7 @@ export function Buttons() {

Buttons

- {['primary', 'secondary', 'negative'].map(color => ( + {['primary', 'secondary', 'secondary_inverted'].map(color => ( {['solid', 'outline', 'ghost'].map(variant => (