about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Button.tsx71
-rw-r--r--src/components/FeedCard.tsx41
-rw-r--r--src/components/FeedInterstitials.tsx354
-rw-r--r--src/components/ProfileCard.tsx87
-rw-r--r--src/components/RichText.tsx26
-rw-r--r--src/components/icons/Arrow.tsx4
-rw-r--r--src/lib/statsig/events.ts5
-rw-r--r--src/view/screens/Feeds.tsx1
-rw-r--r--src/view/screens/Storybook/Buttons.tsx2
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}>