about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Dialog/index.tsx61
-rw-r--r--src/components/Dialog/index.web.tsx48
-rw-r--r--src/components/Dialog/types.ts21
-rw-r--r--src/components/FeedInterstitials.tsx49
-rw-r--r--src/components/InterestTabs.tsx390
-rw-r--r--src/components/LoggedOutCTA.tsx82
-rw-r--r--src/components/PolicyUpdateOverlay/context.tsx4
-rw-r--r--src/components/PostControls/PostMenu/PostMenuItems.tsx25
-rw-r--r--src/components/ProgressGuide/FollowDialog.tsx146
-rw-r--r--src/components/StarterPack/ProfileStarterPacks.tsx6
-rw-r--r--src/components/StarterPack/Wizard/WizardEditListDialog.tsx7
-rw-r--r--src/components/StarterPack/Wizard/WizardListCard.tsx11
-rw-r--r--src/components/Toast/Toast.tsx284
-rw-r--r--src/components/Toast/index.e2e.tsx8
-rw-r--r--src/components/Toast/index.tsx55
-rw-r--r--src/components/Toast/index.web.tsx54
-rw-r--r--src/components/Toast/sonner/index.ts3
-rw-r--r--src/components/Toast/sonner/index.web.ts3
-rw-r--r--src/components/dialogs/BirthDateSettings.tsx91
-rw-r--r--src/components/dialogs/StarterPackDialog.tsx382
-rw-r--r--src/components/dms/MessageItem.tsx1
-rw-r--r--src/components/forms/Toggle.tsx6
-rw-r--r--src/components/moderation/ContentHider.tsx2
-rw-r--r--src/components/verification/VerificationsDialog.tsx9
-rw-r--r--src/components/verification/VerifierDialog.tsx9
25 files changed, 1441 insertions, 316 deletions
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 4795385ee..de8287a53 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -12,9 +12,13 @@ import {
 import {
   KeyboardAwareScrollView,
   useKeyboardHandler,
+  useReanimatedKeyboardAnimation,
 } from 'react-native-keyboard-controller'
-import {runOnJS} from 'react-native-reanimated'
-import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
+import Animated, {
+  runOnJS,
+  type ScrollEvent,
+  useAnimatedStyle,
+} from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -26,7 +30,7 @@ import {isAndroid, isIOS} from '#/platform/detection'
 import {useA11y} from '#/state/a11y'
 import {useDialogStateControlContext} from '#/state/dialogs'
 import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
-import {atoms as a, tokens, useTheme} from '#/alf'
+import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
 import {useThemeName} from '#/alf/util/useColorModeTheme'
 import {Context, useDialogContext} from '#/components/Dialog/context'
 import {
@@ -256,6 +260,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
           contentContainerStyle,
         ]}
         ref={ref}
+        showsVerticalScrollIndicator={isAndroid ? false : undefined}
         {...props}
         bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
         bottomOffset={30}
@@ -275,12 +280,15 @@ export const InnerFlatList = React.forwardRef<
   ListProps<any> & {
     webInnerStyle?: StyleProp<ViewStyle>
     webInnerContentContainerStyle?: StyleProp<ViewStyle>
+    footer?: React.ReactNode
   }
->(function InnerFlatList({style, ...props}, ref) {
+>(function InnerFlatList({footer, style, ...props}, ref) {
   const insets = useSafeAreaInsets()
   const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
 
-  const onScroll = (e: ReanimatedScrollEvent) => {
+  useEnableKeyboardController(isIOS)
+
+  const onScroll = (e: ScrollEvent) => {
     'worklet'
     if (!isAndroid) {
       return
@@ -300,13 +308,54 @@ export const InnerFlatList = React.forwardRef<
         bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
         ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
         ref={ref}
+        showsVerticalScrollIndicator={isAndroid ? false : undefined}
         {...props}
-        style={[style]}
+        style={[a.h_full, style]}
       />
+      {footer}
     </ScrollProvider>
   )
 })
 
+export function FlatListFooter({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  const {top, bottom} = useSafeAreaInsets()
+  const {height} = useReanimatedKeyboardAnimation()
+
+  const animatedStyle = useAnimatedStyle(() => {
+    if (!isIOS) return {}
+    return {
+      transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
+    }
+  })
+
+  return (
+    <Animated.View
+      style={[
+        a.absolute,
+        a.bottom_0,
+        a.w_full,
+        a.z_10,
+        a.border_t,
+        t.atoms.bg,
+        t.atoms.border_contrast_low,
+        a.px_lg,
+        a.pt_md,
+        {
+          paddingBottom: platform({
+            ios: tokens.space.md + bottom,
+            android: tokens.space.md + bottom + top,
+          }),
+        },
+        // TODO: had to admit defeat here, but we should
+        // try and get this to work for Android as well -sfn
+        ios(animatedStyle),
+      ]}>
+      {children}
+    </Animated.View>
+  )
+}
+
 export function Handle({difference = false}: {difference?: boolean}) {
   const t = useTheme()
   const {_} = useLingui()
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 1417e9e91..1d62cbfdc 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -2,6 +2,7 @@ import React, {useImperativeHandle} from 'react'
 import {
   FlatList,
   type FlatListProps,
+  type GestureResponderEvent,
   type StyleProp,
   TouchableWithoutFeedback,
   View,
@@ -32,6 +33,9 @@ export * from '#/components/Dialog/types'
 export * from '#/components/Dialog/utils'
 export {Input} from '#/components/forms/TextField'
 
+// 100 minus 10vh of paddingVertical
+export const WEB_DIALOG_HEIGHT = '80vh'
+
 const stopPropagation = (e: any) => e.stopPropagation()
 const preventDefault = (e: any) => e.preventDefault()
 
@@ -75,9 +79,12 @@ export function Outer({
     [control.id, onClose, setDialogIsOpen],
   )
 
-  const handleBackgroundPress = React.useCallback(async () => {
-    close()
-  }, [close])
+  const handleBackgroundPress = React.useCallback(
+    async (e: GestureResponderEvent) => {
+      webOptions?.onBackgroundPress ? webOptions.onBackgroundPress(e) : close()
+    },
+    [webOptions, close],
+  )
 
   useImperativeHandle(
     control.ref,
@@ -211,9 +218,17 @@ export const InnerFlatList = React.forwardRef<
   FlatListProps<any> & {label: string} & {
     webInnerStyle?: StyleProp<ViewStyle>
     webInnerContentContainerStyle?: StyleProp<ViewStyle>
+    footer?: React.ReactNode
   }
 >(function InnerFlatList(
-  {label, style, webInnerStyle, webInnerContentContainerStyle, ...props},
+  {
+    label,
+    style,
+    webInnerStyle,
+    webInnerContentContainerStyle,
+    footer,
+    ...props
+  },
   ref,
 ) {
   const {gtMobile} = useBreakpoints()
@@ -223,8 +238,7 @@ export const InnerFlatList = React.forwardRef<
       style={[
         a.overflow_hidden,
         a.px_0,
-        // 100 minus 10vh of paddingVertical
-        web({maxHeight: '80vh'}),
+        web({maxHeight: WEB_DIALOG_HEIGHT}),
         webInnerStyle,
       ]}
       contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
@@ -233,10 +247,32 @@ export const InnerFlatList = React.forwardRef<
         style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
         {...props}
       />
+      {footer}
     </Inner>
   )
 })
 
+export function FlatListFooter({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.bottom_0,
+        a.w_full,
+        a.z_10,
+        t.atoms.bg,
+        a.border_t,
+        t.atoms.border_contrast_low,
+        a.px_lg,
+        a.py_md,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
 export function Close() {
   const {_} = useLingui()
   const {close} = React.useContext(Context)
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index 3ca64a321..1308e625c 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -1,15 +1,15 @@
-import React from 'react'
-import type {
-  AccessibilityProps,
-  GestureResponderEvent,
-  ScrollViewProps,
+import {
+  type AccessibilityProps,
+  type GestureResponderEvent,
+  type ScrollViewProps,
+  type StyleProp,
+  type ViewStyle,
 } from 'react-native'
-import {ViewStyle} from 'react-native'
-import {StyleProp} from 'react-native'
+import type React from 'react'
 
-import {ViewStyleProp} from '#/alf'
-import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
-import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
+import {type ViewStyleProp} from '#/alf'
+import {type BottomSheetViewProps} from '../../../modules/bottom-sheet'
+import {type BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
 
 type A11yProps = Required<AccessibilityProps>
 
@@ -64,6 +64,7 @@ export type DialogOuterProps = {
   nativeOptions?: Omit<BottomSheetViewProps, 'children'>
   webOptions?: {
     alignCenter?: boolean
+    onBackgroundPress?: (e: GestureResponderEvent) => void
   }
   testID?: string
 }
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 07ad2d501..6278449a0 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -25,17 +25,18 @@ import {
   type ViewStyleProp,
   web,
 } from '#/alf'
-import {Button} from '#/components/Button'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
-import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
+import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
 import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
-import {InlineLinkText} from '#/components/Link'
+import {InlineLinkText, Link} from '#/components/Link'
 import * as ProfileCard from '#/components/ProfileCard'
 import {Text} from '#/components/Typography'
 import type * as bsky from '#/types/bsky'
 import {ProgressGuideList} from './ProgressGuide/List'
 
 const MOBILE_CARD_WIDTH = 165
+const FINAL_CARD_WIDTH = 120
 
 function CardOuter({
   children,
@@ -420,28 +421,30 @@ export function ProfileGrid({
 }
 
 function SeeMoreSuggestedProfilesCard() {
-  const navigation = useNavigation<NavigationProp>()
+  const t = useTheme()
   const {_} = useLingui()
 
   return (
-    <Button
+    <Link
+      to="/search"
+      color="primary"
       label={_(msg`Browse more accounts on the Explore page`)}
-      style={[a.flex_col]}
-      onPress={() => {
-        navigation.navigate('SearchTab')
-      }}>
-      <CardOuter>
-        <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>
+      style={[
+        a.flex_col,
+        a.align_center,
+        a.justify_center,
+        a.gap_sm,
+        a.p_md,
+        a.rounded_lg,
+        t.atoms.shadow_sm,
+        {width: FINAL_CARD_WIDTH},
+      ]}>
+      <ButtonIcon icon={ArrowRight} size="lg" />
+      <ButtonText
+        style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
+        <Trans>See more</Trans>
+      </ButtonText>
+    </Link>
   )
 }
 
@@ -539,7 +542,7 @@ export function SuggestedFeeds() {
               style={[t.atoms.text_contrast_medium]}>
               <Trans>Browse more suggestions</Trans>
             </InlineLinkText>
-            <Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
+            <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
           </View>
         </View>
       ) : (
@@ -567,7 +570,7 @@ export function SuggestedFeeds() {
                         </Trans>
                       </Text>
 
-                      <Arrow size="xl" />
+                      <ArrowRight size="xl" />
                     </View>
                   </View>
                 </CardOuter>
diff --git a/src/components/InterestTabs.tsx b/src/components/InterestTabs.tsx
new file mode 100644
index 000000000..aec421768
--- /dev/null
+++ b/src/components/InterestTabs.tsx
@@ -0,0 +1,390 @@
+import {useEffect, useRef, useState} from 'react'
+import {
+  type ScrollView,
+  type StyleProp,
+  View,
+  type ViewStyle,
+} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {isWeb} from '#/platform/detection'
+import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
+import {atoms as a, tokens, useTheme, web} from '#/alf'
+import {transparentifyColor} from '#/alf/util/colorGeneration'
+import {Button, ButtonIcon} from '#/components/Button'
+import {
+  ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft,
+  ArrowRight_Stroke2_Corner0_Rounded as ArrowRight,
+} from '#/components/icons/Arrow'
+import {Text} from '#/components/Typography'
+
+/**
+ * Tab component that automatically scrolls the selected tab into view - used for interests
+ * in the Find Follows dialog, Explore screen, etc.
+ */
+export function InterestTabs({
+  onSelectTab,
+  interests,
+  selectedInterest,
+  disabled,
+  interestsDisplayNames,
+  TabComponent = Tab,
+  contentContainerStyle,
+  gutterWidth = tokens.space.lg,
+}: {
+  onSelectTab: (tab: string) => void
+  interests: string[]
+  selectedInterest: string
+  interestsDisplayNames: Record<string, string>
+  /** still allows changing tab, but removes the active state from the selected tab */
+  disabled?: boolean
+  TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
+  contentContainerStyle?: StyleProp<ViewStyle>
+  gutterWidth?: number
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const listRef = useRef<ScrollView>(null)
+  const [totalWidth, setTotalWidth] = useState(0)
+  const [scrollX, setScrollX] = useState(0)
+  const [contentWidth, setContentWidth] = useState(0)
+  const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
+  const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
+
+  const onInitialLayout = useNonReactiveCallback(() => {
+    const index = interests.indexOf(selectedInterest)
+    scrollIntoViewIfNeeded(index)
+  })
+
+  useEffect(() => {
+    if (tabOffsets) {
+      onInitialLayout()
+    }
+  }, [tabOffsets, onInitialLayout])
+
+  function scrollIntoViewIfNeeded(index: number) {
+    const btnLayout = tabOffsets[index]
+    if (!btnLayout) return
+    listRef.current?.scrollTo({
+      // centered
+      x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
+      animated: true,
+    })
+  }
+
+  function handleSelectTab(index: number) {
+    const tab = interests[index]
+    onSelectTab(tab)
+    scrollIntoViewIfNeeded(index)
+  }
+
+  function handleTabLayout(index: number, x: number, width: number) {
+    if (!tabOffsets.length) {
+      pendingTabOffsets.current[index] = {x, width}
+      if (pendingTabOffsets.current.length === interests.length) {
+        setTabOffsets(pendingTabOffsets.current)
+      }
+    }
+  }
+
+  const canScrollLeft = scrollX > 0
+  const canScrollRight = Math.ceil(scrollX) < contentWidth - totalWidth
+
+  const cleanupRef = useRef<(() => void) | null>(null)
+
+  function scrollLeft() {
+    if (isContinuouslyScrollingRef.current) {
+      return
+    }
+    if (listRef.current && canScrollLeft) {
+      const newScrollX = Math.max(0, scrollX - 200)
+      listRef.current.scrollTo({x: newScrollX, animated: true})
+    }
+  }
+
+  function scrollRight() {
+    if (isContinuouslyScrollingRef.current) {
+      return
+    }
+    if (listRef.current && canScrollRight) {
+      const maxScroll = contentWidth - totalWidth
+      const newScrollX = Math.min(maxScroll, scrollX + 200)
+      listRef.current.scrollTo({x: newScrollX, animated: true})
+    }
+  }
+
+  const isContinuouslyScrollingRef = useRef(false)
+
+  function startContinuousScroll(direction: 'left' | 'right') {
+    // Clear any existing continuous scroll
+    if (cleanupRef.current) {
+      cleanupRef.current()
+    }
+
+    let holdTimeout: NodeJS.Timeout | null = null
+    let animationFrame: number | null = null
+    let isActive = true
+    isContinuouslyScrollingRef.current = false
+
+    const cleanup = () => {
+      isActive = false
+      if (holdTimeout) clearTimeout(holdTimeout)
+      if (animationFrame) cancelAnimationFrame(animationFrame)
+      cleanupRef.current = null
+      // Reset flag after a delay to prevent onPress from firing
+      setTimeout(() => {
+        isContinuouslyScrollingRef.current = false
+      }, 100)
+    }
+
+    cleanupRef.current = cleanup
+
+    // Start continuous scrolling after hold delay
+    holdTimeout = setTimeout(() => {
+      if (!isActive) return
+
+      isContinuouslyScrollingRef.current = true
+      let currentScrollPosition = scrollX
+
+      const scroll = () => {
+        if (!isActive || !listRef.current) return
+
+        const scrollAmount = 3
+        const maxScroll = contentWidth - totalWidth
+
+        let newScrollX: number
+        let canContinue = false
+
+        if (direction === 'left' && currentScrollPosition > 0) {
+          newScrollX = Math.max(0, currentScrollPosition - scrollAmount)
+          canContinue = newScrollX > 0
+        } else if (direction === 'right' && currentScrollPosition < maxScroll) {
+          newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount)
+          canContinue = newScrollX < maxScroll
+        } else {
+          return
+        }
+
+        currentScrollPosition = newScrollX
+        listRef.current.scrollTo({x: newScrollX, animated: false})
+
+        if (canContinue && isActive) {
+          animationFrame = requestAnimationFrame(scroll)
+        }
+      }
+
+      scroll()
+    }, 500)
+  }
+
+  function stopContinuousScroll() {
+    if (cleanupRef.current) {
+      cleanupRef.current()
+    }
+  }
+
+  useEffect(() => {
+    return () => {
+      if (cleanupRef.current) {
+        cleanupRef.current()
+      }
+    }
+  }, [])
+
+  return (
+    <View style={[a.relative, a.flex_row]}>
+      <DraggableScrollView
+        ref={listRef}
+        contentContainerStyle={[
+          a.gap_sm,
+          {paddingHorizontal: gutterWidth},
+          contentContainerStyle,
+        ]}
+        showsHorizontalScrollIndicator={false}
+        decelerationRate="fast"
+        snapToOffsets={
+          tabOffsets.length === interests.length
+            ? tabOffsets.map(o => o.x - tokens.space.xl)
+            : undefined
+        }
+        onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
+        onContentSizeChange={width => setContentWidth(width)}
+        onScroll={evt => {
+          const newScrollX = evt.nativeEvent.contentOffset.x
+          setScrollX(newScrollX)
+        }}
+        scrollEventThrottle={16}>
+        {interests.map((interest, i) => {
+          const active = interest === selectedInterest && !disabled
+          return (
+            <TabComponent
+              key={interest}
+              onSelectTab={handleSelectTab}
+              active={active}
+              index={i}
+              interest={interest}
+              interestsDisplayName={interestsDisplayNames[interest]}
+              onLayout={handleTabLayout}
+            />
+          )
+        })}
+      </DraggableScrollView>
+      {isWeb && canScrollLeft && (
+        <View
+          style={[
+            a.absolute,
+            a.top_0,
+            a.left_0,
+            a.bottom_0,
+            a.justify_center,
+            {paddingLeft: gutterWidth},
+            a.pr_md,
+            a.z_10,
+            web({
+              background: `linear-gradient(to right,  ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
+            }),
+          ]}>
+          <Button
+            label={_(msg`Scroll left`)}
+            onPress={scrollLeft}
+            onPressIn={() => startContinuousScroll('left')}
+            onPressOut={stopContinuousScroll}
+            color="secondary"
+            size="small"
+            style={[
+              a.border,
+              t.atoms.border_contrast_low,
+              t.atoms.bg,
+              a.h_full,
+              {aspectRatio: 1},
+              a.rounded_full,
+            ]}>
+            <ButtonIcon icon={ArrowLeft} />
+          </Button>
+        </View>
+      )}
+      {isWeb && canScrollRight && (
+        <View
+          style={[
+            a.absolute,
+            a.top_0,
+            a.right_0,
+            a.bottom_0,
+            a.justify_center,
+            {paddingRight: gutterWidth},
+            a.pl_md,
+            a.z_10,
+            web({
+              background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
+            }),
+          ]}>
+          <Button
+            label={_(msg`Scroll right`)}
+            onPress={scrollRight}
+            onPressIn={() => startContinuousScroll('right')}
+            onPressOut={stopContinuousScroll}
+            color="secondary"
+            size="small"
+            style={[
+              a.border,
+              t.atoms.border_contrast_low,
+              t.atoms.bg,
+              a.h_full,
+              {aspectRatio: 1},
+              a.rounded_full,
+            ]}>
+            <ButtonIcon icon={ArrowRight} />
+          </Button>
+        </View>
+      )}
+    </View>
+  )
+}
+
+function Tab({
+  onSelectTab,
+  interest,
+  active,
+  index,
+  interestsDisplayName,
+  onLayout,
+}: {
+  onSelectTab: (index: number) => void
+  interest: string
+  active: boolean
+  index: number
+  interestsDisplayName: string
+  onLayout: (index: number, x: number, width: number) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const label = active
+    ? _(
+        msg({
+          message: `"${interestsDisplayName}" category (active)`,
+          comment:
+            'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected.',
+        }),
+      )
+    : _(
+        msg({
+          message: `Select "${interestsDisplayName}" category`,
+          comment:
+            'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is not currently active and can be selected.',
+        }),
+      )
+
+  return (
+    <View
+      key={interest}
+      onLayout={e =>
+        onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
+      }>
+      <Button
+        label={label}
+        onPress={() => onSelectTab(index)}
+        // disable focus ring, we handle it
+        style={web({outline: 'none'})}>
+        {({hovered, pressed, focused}) => (
+          <View
+            style={[
+              a.rounded_full,
+              a.px_lg,
+              a.py_sm,
+              a.border,
+              active || hovered || pressed
+                ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
+                : focused
+                  ? {
+                      borderColor: t.palette.primary_300,
+                      backgroundColor: t.palette.primary_25,
+                    }
+                  : [t.atoms.bg, t.atoms.border_contrast_low],
+            ]}>
+            <Text
+              style={[
+                a.font_medium,
+                active || hovered || pressed
+                  ? t.atoms.text
+                  : t.atoms.text_contrast_medium,
+              ]}>
+              {interestsDisplayName}
+            </Text>
+          </View>
+        )}
+      </Button>
+    </View>
+  )
+}
+
+export function boostInterests(boosts?: string[]) {
+  return (_a: string, _b: string) => {
+    const indexA = boosts?.indexOf(_a) ?? -1
+    const indexB = boosts?.indexOf(_b) ?? -1
+    const rankA = indexA === -1 ? Infinity : indexA
+    const rankB = indexB === -1 ? Infinity : indexB
+    return rankA - rankB
+  }
+}
diff --git a/src/components/LoggedOutCTA.tsx b/src/components/LoggedOutCTA.tsx
new file mode 100644
index 000000000..0bafbd45f
--- /dev/null
+++ b/src/components/LoggedOutCTA.tsx
@@ -0,0 +1,82 @@
+import {View, type ViewStyle} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {type Gate} from '#/lib/statsig/gates'
+import {useGate} from '#/lib/statsig/statsig'
+import {isWeb} from '#/platform/detection'
+import {useSession} from '#/state/session'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {Logo} from '#/view/icons/Logo'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+
+interface LoggedOutCTAProps {
+  style?: ViewStyle
+  gateName: Gate
+}
+
+export function LoggedOutCTA({style, gateName}: LoggedOutCTAProps) {
+  const {hasSession} = useSession()
+  const {requestSwitchToAccount} = useLoggedOutViewControls()
+  const gate = useGate()
+  const t = useTheme()
+  const {_} = useLingui()
+
+  // Only show for logged-out users on web
+  if (hasSession || !isWeb) {
+    return null
+  }
+
+  // Check gate at the last possible moment to avoid counting users as exposed when they won't see the element
+  if (!gate(gateName)) {
+    return null
+  }
+
+  return (
+    <View style={[a.pb_md, style]}>
+      <View
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.px_lg,
+          a.py_md,
+          a.rounded_md,
+          a.mb_xs,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <View style={[a.flex_row, a.align_center, a.flex_1, a.pr_md]}>
+          <Logo width={30} style={[a.mr_md]} />
+          <View style={[a.flex_1]}>
+            <Text style={[a.text_lg, a.font_bold, a.leading_snug]}>
+              <Trans>Join Bluesky</Trans>
+            </Text>
+            <Text
+              style={[
+                a.text_md,
+                a.font_medium,
+                a.leading_snug,
+                t.atoms.text_contrast_medium,
+              ]}>
+              <Trans>The open social network.</Trans>
+            </Text>
+          </View>
+        </View>
+        <Button
+          onPress={() => {
+            requestSwitchToAccount({requestedAccount: 'new'})
+          }}
+          label={_(msg`Create account`)}
+          size="small"
+          variant="solid"
+          color="primary">
+          <ButtonText>
+            <Trans>Create account</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/PolicyUpdateOverlay/context.tsx b/src/components/PolicyUpdateOverlay/context.tsx
index abb058d3c..3c65ae375 100644
--- a/src/components/PolicyUpdateOverlay/context.tsx
+++ b/src/components/PolicyUpdateOverlay/context.tsx
@@ -12,6 +12,7 @@ import {
   type PolicyUpdateState,
   usePolicyUpdateState,
 } from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
+import {ENV} from '#/env'
 
 const Context = createContext<{
   state: PolicyUpdateState
@@ -45,8 +46,7 @@ export function Provider({children}: {children?: ReactNode}) {
   const [isReadyToShowOverlay, setIsReadyToShowOverlay] = useState(false)
   const state = usePolicyUpdateState({
     // only enable the policy update overlay in non-test environments
-    enabled:
-      isReadyToShowOverlay && hasSession && process.env.NODE_ENV !== 'test',
+    enabled: isReadyToShowOverlay && hasSession && ENV !== 'e2e',
   })
 
   const ctx = useMemo(
diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx
index 3fd919cd3..2ec0c6a4c 100644
--- a/src/components/PostControls/PostMenu/PostMenuItems.tsx
+++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx
@@ -266,7 +266,9 @@ let PostMenuItems = ({
       feedContext: postFeedContext,
       reqId: postReqId,
     })
-    Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
+    Toast.show(
+      _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
+    )
   }
 
   const onPressShowLess = () => {
@@ -282,7 +284,9 @@ let PostMenuItems = ({
         feedContext: postFeedContext,
       })
     } else {
-      Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
+      Toast.show(
+        _(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
+      )
     }
   }
 
@@ -486,13 +490,16 @@ let PostMenuItems = ({
         )}
 
         {isDiscoverDebugUser && (
-          <Menu.Item
-            testID="postDropdownReportMisclassificationBtn"
-            label={_(msg`Assign topic for algo`)}
-            onPress={onReportMisclassification}>
-            <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
-            <Menu.ItemIcon icon={AtomIcon} position="right" />
-          </Menu.Item>
+          <>
+            <Menu.Divider />
+            <Menu.Item
+              testID="postDropdownReportMisclassificationBtn"
+              label={_(msg`Assign topic for algo`)}
+              onPress={onReportMisclassification}>
+              <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={AtomIcon} position="right" />
+            </Menu.Item>
+          </>
         )}
 
         {hasSession && (
diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx
index 20ebb0abf..f2eb4fa3d 100644
--- a/src/components/ProgressGuide/FollowDialog.tsx
+++ b/src/components/ProgressGuide/FollowDialog.tsx
@@ -1,17 +1,9 @@
 import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
-import {
-  ScrollView,
-  type StyleProp,
-  TextInput,
-  useWindowDimensions,
-  View,
-  type ViewStyle,
-} from 'react-native'
+import {TextInput, useWindowDimensions, View} from 'react-native'
 import {type ModerationOpts} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {logEvent} from '#/lib/statsig/statsig'
 import {isWeb} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -28,7 +20,6 @@ import {
 import {
   atoms as a,
   native,
-  tokens,
   useBreakpoints,
   useTheme,
   type ViewStyleProp,
@@ -40,6 +31,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {boostInterests, InterestTabs} from '#/components/InterestTabs'
 import * as ProfileCard from '#/components/ProfileCard'
 import {Text} from '#/components/Typography'
 import type * as bsky from '#/types/bsky'
@@ -337,12 +329,13 @@ let Header = ({
           }}
           onEscape={control.close}
         />
-        <Tabs
+        <InterestTabs
           onSelectTab={onSelectTab}
           interests={interests}
           selectedInterest={selectedInterest}
-          hasSearchText={!!searchText}
+          disabled={!!searchText}
           interestsDisplayNames={interestsDisplayNames}
+          TabComponent={Tab}
         />
       </View>
     </View>
@@ -403,99 +396,6 @@ function HeaderTop({guide}: {guide: Follow10ProgressGuide}) {
   )
 }
 
-let Tabs = ({
-  onSelectTab,
-  interests,
-  selectedInterest,
-  hasSearchText,
-  interestsDisplayNames,
-  TabComponent = Tab,
-  contentContainerStyle,
-}: {
-  onSelectTab: (tab: string) => void
-  interests: string[]
-  selectedInterest: string
-  hasSearchText: boolean
-  interestsDisplayNames: Record<string, string>
-  TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
-  contentContainerStyle?: StyleProp<ViewStyle>
-}): React.ReactNode => {
-  const listRef = useRef<ScrollView>(null)
-  const [totalWidth, setTotalWidth] = useState(0)
-  const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
-  const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
-
-  const onInitialLayout = useNonReactiveCallback(() => {
-    const index = interests.indexOf(selectedInterest)
-    scrollIntoViewIfNeeded(index)
-  })
-
-  useEffect(() => {
-    if (tabOffsets) {
-      onInitialLayout()
-    }
-  }, [tabOffsets, onInitialLayout])
-
-  function scrollIntoViewIfNeeded(index: number) {
-    const btnLayout = tabOffsets[index]
-    if (!btnLayout) return
-    listRef.current?.scrollTo({
-      // centered
-      x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
-      animated: true,
-    })
-  }
-
-  function handleSelectTab(index: number) {
-    const tab = interests[index]
-    onSelectTab(tab)
-    scrollIntoViewIfNeeded(index)
-  }
-
-  function handleTabLayout(index: number, x: number, width: number) {
-    if (!tabOffsets.length) {
-      pendingTabOffsets.current[index] = {x, width}
-      if (pendingTabOffsets.current.length === interests.length) {
-        setTabOffsets(pendingTabOffsets.current)
-      }
-    }
-  }
-
-  return (
-    <ScrollView
-      ref={listRef}
-      horizontal
-      contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]}
-      showsHorizontalScrollIndicator={false}
-      decelerationRate="fast"
-      snapToOffsets={
-        tabOffsets.length === interests.length
-          ? tabOffsets.map(o => o.x - tokens.space.xl)
-          : undefined
-      }
-      onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
-      scrollEventThrottle={200} // big throttle
-    >
-      {interests.map((interest, i) => {
-        const active = interest === selectedInterest && !hasSearchText
-        return (
-          <TabComponent
-            key={interest}
-            onSelectTab={handleSelectTab}
-            active={active}
-            index={i}
-            interest={interest}
-            interestsDisplayName={interestsDisplayNames[interest]}
-            onLayout={handleTabLayout}
-          />
-        )
-      })}
-    </ScrollView>
-  )
-}
-Tabs = memo(Tabs)
-export {Tabs}
-
 let Tab = ({
   onSelectTab,
   interest,
@@ -513,24 +413,36 @@ let Tab = ({
 }): React.ReactNode => {
   const t = useTheme()
   const {_} = useLingui()
-  const activeText = active ? _(msg` (active)`) : ''
+  const label = active
+    ? _(
+        msg({
+          message: `Search for "${interestsDisplayName}" (active)`,
+          comment:
+            'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.',
+        }),
+      )
+    : _(
+        msg({
+          message: `Search for "${interestsDisplayName}"`,
+          comment:
+            'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.',
+        }),
+      )
   return (
     <View
       key={interest}
       onLayout={e =>
         onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
       }>
-      <Button
-        label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
-        onPress={() => onSelectTab(index)}>
-        {({hovered, pressed, focused}) => (
+      <Button label={label} onPress={() => onSelectTab(index)}>
+        {({hovered, pressed}) => (
           <View
             style={[
               a.rounded_full,
               a.px_lg,
               a.py_sm,
               a.border,
-              active || hovered || pressed || focused
+              active || hovered || pressed
                 ? [
                     t.atoms.bg_contrast_25,
                     {borderColor: t.atoms.bg_contrast_25.backgroundColor},
@@ -540,7 +452,7 @@ let Tab = ({
             <Text
               style={[
                 a.font_medium,
-                active || hovered || pressed || focused
+                active || hovered || pressed
                   ? t.atoms.text
                   : t.atoms.text_contrast_medium,
               ]}>
@@ -759,13 +671,3 @@ function Empty({message}: {message: string}) {
     </View>
   )
 }
-
-export function boostInterests(boosts?: string[]) {
-  return (_a: string, _b: string) => {
-    const indexA = boosts?.indexOf(_a) ?? -1
-    const indexB = boosts?.indexOf(_b) ?? -1
-    const rankA = indexA === -1 ? Infinity : indexA
-    const rankB = indexB === -1 ? Infinity : indexB
-    return rankA - rankB
-  }
-}
diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx
index de19b0bce..73aee28f4 100644
--- a/src/components/StarterPack/ProfileStarterPacks.tsx
+++ b/src/components/StarterPack/ProfileStarterPacks.tsx
@@ -180,7 +180,7 @@ function CreateAnother() {
         color="secondary"
         size="small"
         style={[a.self_center]}
-        onPress={() => navigation.navigate('StarterPackWizard')}>
+        onPress={() => navigation.navigate('StarterPackWizard', {})}>
         <ButtonText>
           <Trans>Create another</Trans>
         </ButtonText>
@@ -238,7 +238,7 @@ function Empty() {
     ],
   })
   const navToWizard = useCallback(() => {
-    navigation.navigate('StarterPackWizard')
+    navigation.navigate('StarterPackWizard', {})
   }, [navigation])
   const wrappedNavToWizard = requireEmailVerification(navToWizard, {
     instructions: [
@@ -322,7 +322,7 @@ function Empty() {
             color="secondary"
             cta={_(msg`Let me choose`)}
             onPress={() => {
-              navigation.navigate('StarterPackWizard')
+              navigation.navigate('StarterPackWizard', {})
             }}
           />
         </Prompt.Actions>
diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
index 731323f7f..7dfde900f 100644
--- a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
+++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
@@ -11,7 +11,6 @@ import {useLingui} from '@lingui/react'
 
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
 import {isWeb} from '#/platform/detection'
-import {useSession} from '#/state/session'
 import {type ListMethods} from '#/view/com/util/List'
 import {
   type WizardAction,
@@ -48,7 +47,6 @@ export function WizardEditListDialog({
 }) {
   const {_} = useLingui()
   const t = useTheme()
-  const {currentAccount} = useSession()
   const initialNumToRender = useInitialNumToRender()
 
   const listRef = useRef<ListMethods>(null)
@@ -56,10 +54,7 @@ export function WizardEditListDialog({
   const getData = () => {
     if (state.currentStep === 'Feeds') return state.feeds
 
-    return [
-      profile,
-      ...state.profiles.filter(p => p.did !== currentAccount?.did),
-    ]
+    return [profile, ...state.profiles.filter(p => p.did !== profile.did)]
   }
 
   const renderItem = ({item}: ListRenderItemInfo<any>) =>
diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx
index fbaa185a9..09c265d78 100644
--- a/src/components/StarterPack/Wizard/WizardListCard.tsx
+++ b/src/components/StarterPack/Wizard/WizardListCard.tsx
@@ -131,10 +131,13 @@ export function WizardProfileCard({
 }) {
   const {currentAccount} = useSession()
 
-  const isMe = profile.did === currentAccount?.did
-  const included = isMe || state.profiles.some(p => p.did === profile.did)
+  // Determine the "main" profile for this starter pack - either targetDid or current account
+  const targetProfileDid = state.targetDid || currentAccount?.did
+  const isTarget = profile.did === targetProfileDid
+  const included = isTarget || state.profiles.some(p => p.did === profile.did)
   const disabled =
-    isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
+    isTarget ||
+    (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
   const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
   const displayName = profile.displayName
     ? sanitizeDisplayName(profile.displayName)
@@ -144,7 +147,7 @@ export function WizardProfileCard({
     if (disabled) return
 
     Keyboard.dismiss()
-    if (profile.did === currentAccount?.did) return
+    if (profile.did === targetProfileDid) return
 
     if (!included) {
       dispatch({type: 'AddProfile', profile})
diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx
index 4d782597d..ac5bc4889 100644
--- a/src/components/Toast/Toast.tsx
+++ b/src/components/Toast/Toast.tsx
@@ -1,22 +1,20 @@
 import {createContext, useContext, useMemo} from 'react'
-import {View} from 'react-native'
+import {type GestureResponderEvent, View} from 'react-native'
 
 import {atoms as a, select, useAlf, useTheme} from '#/alf'
+import {
+  Button,
+  type ButtonProps,
+  type UninheritableButtonProps,
+} from '#/components/Button'
+import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
+import {type Props as SVGIconProps} from '#/components/icons/common'
 import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import {dismiss} from '#/components/Toast/sonner'
 import {type ToastType} from '#/components/Toast/types'
-import {Text} from '#/components/Typography'
-import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '../icons/CircleCheck'
-
-type ContextType = {
-  type: ToastType
-}
-
-export type ToastComponentProps = {
-  type?: ToastType
-  content: React.ReactNode
-}
+import {Text as BaseText} from '#/components/Typography'
 
 export const ICONS = {
   default: CircleCheck,
@@ -26,81 +24,225 @@ export const ICONS = {
   info: CircleInfo,
 }
 
-const Context = createContext<ContextType>({
+const ToastConfigContext = createContext<{
+  id: string
+  type: ToastType
+}>({
+  id: '',
   type: 'default',
 })
-Context.displayName = 'ToastContext'
+ToastConfigContext.displayName = 'ToastConfigContext'
 
-export function Toast({type = 'default', content}: ToastComponentProps) {
-  const {fonts} = useAlf()
+export function ToastConfigProvider({
+  children,
+  id,
+  type,
+}: {
+  children: React.ReactNode
+  id: string
+  type: ToastType
+}) {
+  return (
+    <ToastConfigContext.Provider
+      value={useMemo(() => ({id, type}), [id, type])}>
+      {children}
+    </ToastConfigContext.Provider>
+  )
+}
+
+export function Outer({children}: {children: React.ReactNode}) {
   const t = useTheme()
+  const {type} = useContext(ToastConfigContext)
   const styles = useToastStyles({type})
-  const Icon = ICONS[type]
-  /**
-   * Vibes-based number, adjusts `top` of `View` that wraps the text to
-   * compensate for different type sizes and keep the first line of text
-   * aligned with the icon. - esb
-   */
-  const fontScaleCompensation = useMemo(
-    () => parseInt(fonts.scale) * -1 * 0.65,
-    [fonts.scale],
-  )
 
   return (
-    <Context.Provider value={useMemo(() => ({type}), [type])}>
-      <View
-        style={[
-          a.flex_1,
-          a.py_lg,
-          a.pl_xl,
-          a.pr_2xl,
-          a.rounded_md,
-          a.border,
-          a.flex_row,
-          a.gap_sm,
-          t.atoms.shadow_sm,
-          {
-            backgroundColor: styles.backgroundColor,
-            borderColor: styles.borderColor,
-          },
-        ]}>
-        <Icon size="md" fill={styles.iconColor} />
-
-        <View
-          style={[
-            a.flex_1,
-            {
-              top: fontScaleCompensation,
-            },
-          ]}>
-          {typeof content === 'string' ? (
-            <ToastText>{content}</ToastText>
-          ) : (
-            content
-          )}
-        </View>
-      </View>
-    </Context.Provider>
+    <View
+      style={[
+        a.flex_1,
+        a.p_lg,
+        a.rounded_md,
+        a.border,
+        a.flex_row,
+        a.gap_sm,
+        t.atoms.shadow_sm,
+        {
+          paddingVertical: 14, // 16 seems too big
+          backgroundColor: styles.backgroundColor,
+          borderColor: styles.borderColor,
+        },
+      ]}>
+      {children}
+    </View>
   )
 }
 
-export function ToastText({children}: {children: React.ReactNode}) {
-  const {type} = useContext(Context)
+export function Icon({icon}: {icon?: React.ComponentType<SVGIconProps>}) {
+  const {type} = useContext(ToastConfigContext)
+  const styles = useToastStyles({type})
+  const IconComponent = icon || ICONS[type]
+  return <IconComponent size="md" fill={styles.iconColor} />
+}
+
+export function Text({children}: {children: React.ReactNode}) {
+  const {type} = useContext(ToastConfigContext)
   const {textColor} = useToastStyles({type})
+  const {fontScaleCompensation} = useToastFontScaleCompensation()
   return (
-    <Text
-      selectable={false}
+    <View
       style={[
-        a.text_md,
-        a.font_medium,
-        a.leading_snug,
-        a.pointer_events_none,
+        a.flex_1,
+        a.pr_lg,
         {
-          color: textColor,
+          top: fontScaleCompensation,
         },
       ]}>
-      {children}
-    </Text>
+      <BaseText
+        selectable={false}
+        style={[
+          a.text_md,
+          a.font_medium,
+          a.leading_snug,
+          a.pointer_events_none,
+          {
+            color: textColor,
+          },
+        ]}>
+        {children}
+      </BaseText>
+    </View>
+  )
+}
+
+export function Action(
+  props: Omit<ButtonProps, UninheritableButtonProps | 'children'> & {
+    children: React.ReactNode
+  },
+) {
+  const t = useTheme()
+  const {fontScaleCompensation} = useToastFontScaleCompensation()
+  const {type} = useContext(ToastConfigContext)
+  const {id} = useContext(ToastConfigContext)
+  const styles = useMemo(() => {
+    const base = {
+      base: {
+        textColor: t.palette.contrast_600,
+        backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+      },
+      interacted: {
+        textColor: t.atoms.text.color,
+        backgroundColor: t.atoms.bg_contrast_50.backgroundColor,
+      },
+    }
+    return {
+      default: base,
+      success: {
+        base: {
+          textColor: select(t.name, {
+            light: t.palette.primary_800,
+            dim: t.palette.primary_900,
+            dark: t.palette.primary_900,
+          }),
+          backgroundColor: t.palette.primary_25,
+        },
+        interacted: {
+          textColor: select(t.name, {
+            light: t.palette.primary_900,
+            dim: t.palette.primary_975,
+            dark: t.palette.primary_975,
+          }),
+          backgroundColor: t.palette.primary_50,
+        },
+      },
+      error: {
+        base: {
+          textColor: select(t.name, {
+            light: t.palette.negative_700,
+            dim: t.palette.negative_900,
+            dark: t.palette.negative_900,
+          }),
+          backgroundColor: t.palette.negative_25,
+        },
+        interacted: {
+          textColor: select(t.name, {
+            light: t.palette.negative_900,
+            dim: t.palette.negative_975,
+            dark: t.palette.negative_975,
+          }),
+          backgroundColor: t.palette.negative_50,
+        },
+      },
+      warning: base,
+      info: base,
+    }[type]
+  }, [t, type])
+
+  const onPress = (e: GestureResponderEvent) => {
+    console.log('Toast Action pressed, dismissing toast', id)
+    dismiss(id)
+    props.onPress?.(e)
+  }
+
+  return (
+    <View style={{top: fontScaleCompensation}}>
+      <Button {...props} onPress={onPress}>
+        {s => {
+          const interacted = s.pressed || s.hovered || s.focused
+          return (
+            <>
+              <View
+                style={[
+                  a.absolute,
+                  a.curve_continuous,
+                  {
+                    // tiny button styles
+                    top: -5,
+                    bottom: -5,
+                    left: -9,
+                    right: -9,
+                    borderRadius: 6,
+                    backgroundColor: interacted
+                      ? styles.interacted.backgroundColor
+                      : styles.base.backgroundColor,
+                  },
+                ]}
+              />
+              <BaseText
+                style={[
+                  a.text_md,
+                  a.font_medium,
+                  a.leading_snug,
+                  {
+                    color: interacted
+                      ? styles.interacted.textColor
+                      : styles.base.textColor,
+                  },
+                ]}>
+                {props.children}
+              </BaseText>
+            </>
+          )
+        }}
+      </Button>
+    </View>
+  )
+}
+
+/**
+ * Vibes-based number, provides t `top` value to wrap the text to compensate
+ * for different type sizes and keep the first line of text aligned with the
+ * icon. - esb
+ */
+function useToastFontScaleCompensation() {
+  const {fonts} = useAlf()
+  const fontScaleCompensation = useMemo(
+    () => parseInt(fonts.scale) * -1 * 0.65,
+    [fonts.scale],
+  )
+  return useMemo(
+    () => ({
+      fontScaleCompensation,
+    }),
+    [fontScaleCompensation],
   )
 }
 
diff --git a/src/components/Toast/index.e2e.tsx b/src/components/Toast/index.e2e.tsx
index 357bd8dda..a4056323d 100644
--- a/src/components/Toast/index.e2e.tsx
+++ b/src/components/Toast/index.e2e.tsx
@@ -1,3 +1,11 @@
+export const DURATION = 0
+
+export const Action = () => null
+export const Icon = () => null
+export const Outer = () => null
+export const Text = () => null
+export const ToastConfigProvider = () => null
+
 export function ToastOutlet() {
   return null
 }
diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx
index 286d414a1..d70a8ad16 100644
--- a/src/components/Toast/index.tsx
+++ b/src/components/Toast/index.tsx
@@ -1,15 +1,21 @@
+import React from 'react'
 import {View} from 'react-native'
+import {nanoid} from 'nanoid/non-secure'
 import {toast as sonner, Toaster} from 'sonner-native'
 
 import {atoms as a} from '#/alf'
 import {DURATION} from '#/components/Toast/const'
 import {
-  Toast as BaseToast,
-  type ToastComponentProps,
+  Icon as ToastIcon,
+  Outer as BaseOuter,
+  Text as ToastText,
+  ToastConfigProvider,
 } from '#/components/Toast/Toast'
 import {type BaseToastOptions} from '#/components/Toast/types'
 
 export {DURATION} from '#/components/Toast/const'
+export {Action, Icon, Text, ToastConfigProvider} from '#/components/Toast/Toast'
+export {type ToastType} from '#/components/Toast/types'
 
 /**
  * Toasts are rendered in a global outlet, which is placed at the top of the
@@ -19,13 +25,10 @@ export function ToastOutlet() {
   return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
 }
 
-/**
- * The toast UI component
- */
-export function Toast({type, content}: ToastComponentProps) {
+export function Outer({children}: {children: React.ReactNode}) {
   return (
     <View style={[a.px_xl, a.w_full]}>
-      <BaseToast content={content} type={type} />
+      <BaseOuter>{children}</BaseOuter>
     </View>
   )
 }
@@ -40,10 +43,38 @@ export const api = sonner
  */
 export function show(
   content: React.ReactNode,
-  {type, ...options}: BaseToastOptions = {},
+  {type = 'default', ...options}: BaseToastOptions = {},
 ) {
-  sonner.custom(<Toast content={content} type={type} />, {
-    ...options,
-    duration: options?.duration ?? DURATION,
-  })
+  const id = nanoid()
+
+  if (typeof content === 'string') {
+    sonner.custom(
+      <ToastConfigProvider id={id} type={type}>
+        <Outer>
+          <ToastIcon />
+          <ToastText>{content}</ToastText>
+        </Outer>
+      </ToastConfigProvider>,
+      {
+        ...options,
+        id,
+        duration: options?.duration ?? DURATION,
+      },
+    )
+  } else if (React.isValidElement(content)) {
+    sonner.custom(
+      <ToastConfigProvider id={id} type={type}>
+        {content}
+      </ToastConfigProvider>,
+      {
+        ...options,
+        id,
+        duration: options?.duration ?? DURATION,
+      },
+    )
+  } else {
+    throw new Error(
+      `Toast can be a string or a React element, got ${typeof content}`,
+    )
+  }
 }
diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx
index 857ed7b39..8b2028db9 100644
--- a/src/components/Toast/index.web.tsx
+++ b/src/components/Toast/index.web.tsx
@@ -1,10 +1,21 @@
+import React from 'react'
+import {nanoid} from 'nanoid/non-secure'
 import {toast as sonner, Toaster} from 'sonner'
 
 import {atoms as a} from '#/alf'
 import {DURATION} from '#/components/Toast/const'
-import {Toast} from '#/components/Toast/Toast'
+import {
+  Icon as ToastIcon,
+  Outer as ToastOuter,
+  Text as ToastText,
+  ToastConfigProvider,
+} from '#/components/Toast/Toast'
 import {type BaseToastOptions} from '#/components/Toast/types'
 
+export {DURATION} from '#/components/Toast/const'
+export * from '#/components/Toast/Toast'
+export {type ToastType} from '#/components/Toast/types'
+
 /**
  * Toasts are rendered in a global outlet, which is placed at the top of the
  * component tree.
@@ -30,11 +41,40 @@ export const api = sonner
  */
 export function show(
   content: React.ReactNode,
-  {type, ...options}: BaseToastOptions = {},
+  {type = 'default', ...options}: BaseToastOptions = {},
 ) {
-  sonner(<Toast content={content} type={type} />, {
-    unstyled: true, // required on web
-    ...options,
-    duration: options?.duration ?? DURATION,
-  })
+  const id = nanoid()
+
+  if (typeof content === 'string') {
+    sonner(
+      <ToastConfigProvider id={id} type={type}>
+        <ToastOuter>
+          <ToastIcon />
+          <ToastText>{content}</ToastText>
+        </ToastOuter>
+      </ToastConfigProvider>,
+      {
+        ...options,
+        unstyled: true, // required on web
+        id,
+        duration: options?.duration ?? DURATION,
+      },
+    )
+  } else if (React.isValidElement(content)) {
+    sonner(
+      <ToastConfigProvider id={id} type={type}>
+        {content}
+      </ToastConfigProvider>,
+      {
+        ...options,
+        unstyled: true, // required on web
+        id,
+        duration: options?.duration ?? DURATION,
+      },
+    )
+  } else {
+    throw new Error(
+      `Toast can be a string or a React element, got ${typeof content}`,
+    )
+  }
 }
diff --git a/src/components/Toast/sonner/index.ts b/src/components/Toast/sonner/index.ts
new file mode 100644
index 000000000..35f8552c7
--- /dev/null
+++ b/src/components/Toast/sonner/index.ts
@@ -0,0 +1,3 @@
+import {toast} from 'sonner-native'
+
+export const dismiss = toast.dismiss
diff --git a/src/components/Toast/sonner/index.web.ts b/src/components/Toast/sonner/index.web.ts
new file mode 100644
index 000000000..12c4741d6
--- /dev/null
+++ b/src/components/Toast/sonner/index.web.ts
@@ -0,0 +1,3 @@
+import {toast} from 'sonner'
+
+export const dismiss = toast.dismiss
diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx
index fecfc43bc..0b8dfb540 100644
--- a/src/components/dialogs/BirthDateSettings.tsx
+++ b/src/components/dialogs/BirthDateSettings.tsx
@@ -4,21 +4,23 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {cleanError} from '#/lib/strings/errors'
-import {getDateAgo} from '#/lib/strings/time'
+import {getAge, getDateAgo} from '#/lib/strings/time'
 import {logger} from '#/logger'
 import {isIOS, isWeb} from '#/platform/detection'
 import {
   usePreferencesQuery,
-  UsePreferencesQueryResponse,
+  type UsePreferencesQueryResponse,
   usePreferencesSetBirthDateMutation,
 } from '#/state/queries/preferences'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {DateField} from '#/components/forms/DateField'
+import {InlineLinkText} from '#/components/Link'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
-import {Button, ButtonIcon, ButtonText} from '../Button'
 
 export function BirthDateSettingsDialog({
   control,
@@ -32,31 +34,35 @@ export function BirthDateSettingsDialog({
   return (
     <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
       <Dialog.Handle />
-      <Dialog.ScrollableInner label={_(msg`My Birthday`)}>
-        <View style={[a.gap_sm, a.pb_lg]}>
-          <Text style={[a.text_2xl, a.font_bold]}>
+      <Dialog.ScrollableInner
+        label={_(msg`My Birthday`)}
+        style={web({maxWidth: 400})}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.text_xl, a.font_bold]}>
             <Trans>My Birthday</Trans>
           </Text>
-          <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
-            <Trans>This information is not shared with other users.</Trans>
+          <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              This information is private and not shared with other users.
+            </Trans>
           </Text>
-        </View>
 
-        {isLoading ? (
-          <Loader size="xl" />
-        ) : error || !preferences ? (
-          <ErrorMessage
-            message={
-              error?.toString() ||
-              _(
-                msg`We were unable to load your birth date preferences. Please try again.`,
-              )
-            }
-            style={[a.rounded_sm]}
-          />
-        ) : (
-          <BirthdayInner control={control} preferences={preferences} />
-        )}
+          {isLoading ? (
+            <Loader size="xl" />
+          ) : error || !preferences ? (
+            <ErrorMessage
+              message={
+                error?.toString() ||
+                _(
+                  msg`We were unable to load your birth date preferences. Please try again.`,
+                )
+              }
+              style={[a.rounded_sm]}
+            />
+          ) : (
+            <BirthdayInner control={control} preferences={preferences} />
+          )}
+        </View>
 
         <Dialog.Close />
       </Dialog.ScrollableInner>
@@ -72,7 +78,9 @@ function BirthdayInner({
   preferences: UsePreferencesQueryResponse
 }) {
   const {_} = useLingui()
-  const [date, setDate] = React.useState(preferences.birthDate || new Date())
+  const [date, setDate] = React.useState(
+    preferences.birthDate || getDateAgo(18),
+  )
   const {
     isPending,
     isError,
@@ -81,6 +89,10 @@ function BirthdayInner({
   } = usePreferencesSetBirthDateMutation()
   const hasChanged = date !== preferences.birthDate
 
+  const age = getAge(new Date(date))
+  const isUnder13 = age < 13
+  const isUnder18 = age >= 13 && age < 18
+
   const onSave = React.useCallback(async () => {
     try {
       // skip if date is the same
@@ -102,10 +114,32 @@ function BirthdayInner({
           onChangeDate={newDate => setDate(new Date(newDate))}
           label={_(msg`Birthday`)}
           accessibilityHint={_(msg`Enter your birth date`)}
-          maximumDate={getDateAgo(13)}
         />
       </View>
 
+      {isUnder18 && hasChanged && (
+        <Admonition type="info">
+          <Trans>
+            The birthdate you've entered means you are under 18 years old.
+            Certain content and features may be unavailable to you.
+          </Trans>
+        </Admonition>
+      )}
+
+      {isUnder13 && (
+        <Admonition type="error">
+          <Trans>
+            You must be at least 13 years old to use Bluesky. Read our{' '}
+            <InlineLinkText
+              to="https://bsky.social/about/support/tos"
+              label={_(msg`Terms of Service`)}>
+              Terms of Service
+            </InlineLinkText>{' '}
+            for more information.
+          </Trans>
+        </Admonition>
+      )}
+
       {isError ? (
         <ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} />
       ) : undefined}
@@ -116,7 +150,8 @@ function BirthdayInner({
           size="large"
           onPress={onSave}
           variant="solid"
-          color="primary">
+          color="primary"
+          disabled={isUnder13}>
           <ButtonText>
             {hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>}
           </ButtonText>
diff --git a/src/components/dialogs/StarterPackDialog.tsx b/src/components/dialogs/StarterPackDialog.tsx
new file mode 100644
index 000000000..6a502072c
--- /dev/null
+++ b/src/components/dialogs/StarterPackDialog.tsx
@@ -0,0 +1,382 @@
+import {useCallback, useState} from 'react'
+import {View} from 'react-native'
+import {
+  type AppBskyGraphGetStarterPacksWithMembership,
+  AppBskyGraphStarterpack,
+} from '@atproto/api'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
+import {type NavigationProp} from '#/lib/routes/types'
+import {isWeb} from '#/platform/detection'
+import {
+  invalidateActorStarterPacksWithMembershipQuery,
+  useActorStarterPacksWithMembershipsQuery,
+} from '#/state/queries/actor-starter-packs'
+import {
+  useListMembershipAddMutation,
+  useListMembershipRemoveMutation,
+} from '#/state/queries/list-memberships'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {AvatarStack} from '#/components/AvatarStack'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+import {StarterPack} from '#/components/icons/StarterPack'
+import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import * as bsky from '#/types/bsky'
+
+type StarterPackWithMembership =
+  AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership
+
+export type StarterPackDialogProps = {
+  control: Dialog.DialogControlProps
+  targetDid: string
+  enabled?: boolean
+}
+
+export function StarterPackDialog({
+  control,
+  targetDid,
+  enabled,
+}: StarterPackDialogProps) {
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+  const requireEmailVerification = useRequireEmailVerification()
+
+  const navToWizard = useCallback(() => {
+    control.close()
+    navigation.navigate('StarterPackWizard', {
+      fromDialog: true,
+      targetDid: targetDid,
+      onSuccess: () => {
+        setTimeout(() => {
+          if (!control.isOpen) {
+            control.open()
+          }
+        }, 0)
+      },
+    })
+  }, [navigation, control, targetDid])
+
+  const wrappedNavToWizard = requireEmailVerification(navToWizard, {
+    instructions: [
+      <Trans key="nav">
+        Before creating a starter pack, you must first verify your email.
+      </Trans>,
+    ],
+  })
+
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <StarterPackList
+        onStartWizard={wrappedNavToWizard}
+        targetDid={targetDid}
+        enabled={enabled}
+      />
+    </Dialog.Outer>
+  )
+}
+
+function Empty({onStartWizard}: {onStartWizard: () => void}) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  return (
+    <View style={[a.gap_2xl, {paddingTop: isWeb ? 100 : 64}]}>
+      <View style={[a.gap_xs, a.align_center]}>
+        <StarterPack
+          width={48}
+          fill={t.atoms.border_contrast_medium.borderColor}
+        />
+        <Text style={[a.text_center]}>
+          <Trans>You have no starter packs.</Trans>
+        </Text>
+      </View>
+
+      <View style={[a.align_center]}>
+        <Button
+          label={_(msg`Create starter pack`)}
+          color="secondary_inverted"
+          size="small"
+          onPress={onStartWizard}>
+          <ButtonText>
+            <Trans comment="Text on button to create a new starter pack">
+              Create
+            </Trans>
+          </ButtonText>
+          <ButtonIcon icon={PlusIcon} />
+        </Button>
+      </View>
+    </View>
+  )
+}
+
+function StarterPackList({
+  onStartWizard,
+  targetDid,
+  enabled,
+}: {
+  onStartWizard: () => void
+  targetDid: string
+  enabled?: boolean
+}) {
+  const control = Dialog.useDialogContext()
+  const {_} = useLingui()
+
+  const {
+    data,
+    isError,
+    isLoading,
+    hasNextPage,
+    isFetchingNextPage,
+    fetchNextPage,
+  } = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled})
+
+  const membershipItems =
+    data?.pages.flatMap(page => page.starterPacksWithMembership) || []
+
+  const onEndReached = useCallback(async () => {
+    if (isFetchingNextPage || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      // Error handling is optional since this is just pagination
+    }
+  }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = useCallback(
+    ({item}: {item: StarterPackWithMembership}) => (
+      <StarterPackItem starterPackWithMembership={item} targetDid={targetDid} />
+    ),
+    [targetDid],
+  )
+
+  const onClose = useCallback(() => {
+    control.close()
+  }, [control])
+
+  const listHeader = (
+    <>
+      <View
+        style={[
+          {justifyContent: 'space-between', flexDirection: 'row'},
+          isWeb ? a.mb_2xl : a.my_lg,
+          a.align_center,
+        ]}>
+        <Text style={[a.text_lg, a.font_bold]}>
+          <Trans>Add to starter packs</Trans>
+        </Text>
+        <Button
+          label={_(msg`Close`)}
+          onPress={onClose}
+          variant="ghost"
+          color="secondary"
+          size="small"
+          shape="round">
+          <ButtonIcon icon={XIcon} />
+        </Button>
+      </View>
+      {membershipItems.length > 0 && (
+        <>
+          <View
+            style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
+            <Text style={[a.text_md, a.font_bold]}>
+              <Trans>New starter pack</Trans>
+            </Text>
+            <Button
+              label={_(msg`Create starter pack`)}
+              color="secondary_inverted"
+              size="small"
+              onPress={onStartWizard}>
+              <ButtonText>
+                <Trans comment="Text on button to create a new starter pack">
+                  Create
+                </Trans>
+              </ButtonText>
+              <ButtonIcon icon={PlusIcon} />
+            </Button>
+          </View>
+          <Divider />
+        </>
+      )}
+    </>
+  )
+
+  return (
+    <Dialog.InnerFlatList
+      data={isLoading ? [{}] : membershipItems}
+      renderItem={
+        isLoading
+          ? () => (
+              <View style={[a.align_center, a.py_2xl]}>
+                <Loader size="xl" />
+              </View>
+            )
+          : renderItem
+      }
+      keyExtractor={
+        isLoading
+          ? () => 'starter_pack_dialog_loader'
+          : (item: StarterPackWithMembership) => item.starterPack.uri
+      }
+      onEndReached={onEndReached}
+      onEndReachedThreshold={0.1}
+      ListHeaderComponent={listHeader}
+      ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
+      style={isWeb ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]}
+    />
+  )
+}
+
+function StarterPackItem({
+  starterPackWithMembership,
+  targetDid,
+}: {
+  starterPackWithMembership: StarterPackWithMembership
+  targetDid: string
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const queryClient = useQueryClient()
+
+  const starterPack = starterPackWithMembership.starterPack
+  const isInPack = !!starterPackWithMembership.listItem
+
+  const [isPendingRefresh, setIsPendingRefresh] = useState(false)
+
+  const {mutate: addMembership} = useListMembershipAddMutation({
+    onSuccess: () => {
+      Toast.show(_(msg`Added to starter pack`))
+      // Use a timeout to wait for the appview to update, matching the pattern
+      // in list-memberships.ts
+      setTimeout(() => {
+        invalidateActorStarterPacksWithMembershipQuery({
+          queryClient,
+          did: targetDid,
+        })
+        setIsPendingRefresh(false)
+      }, 1e3)
+    },
+    onError: () => {
+      Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
+      setIsPendingRefresh(false)
+    },
+  })
+
+  const {mutate: removeMembership} = useListMembershipRemoveMutation({
+    onSuccess: () => {
+      Toast.show(_(msg`Removed from starter pack`))
+      // Use a timeout to wait for the appview to update, matching the pattern
+      // in list-memberships.ts
+      setTimeout(() => {
+        invalidateActorStarterPacksWithMembershipQuery({
+          queryClient,
+          did: targetDid,
+        })
+        setIsPendingRefresh(false)
+      }, 1e3)
+    },
+    onError: () => {
+      Toast.show(_(msg`Failed to remove from starter pack`), 'xmark')
+      setIsPendingRefresh(false)
+    },
+  })
+
+  const handleToggleMembership = () => {
+    if (!starterPack.list?.uri || isPendingRefresh) return
+
+    const listUri = starterPack.list.uri
+
+    setIsPendingRefresh(true)
+
+    if (!isInPack) {
+      addMembership({
+        listUri: listUri,
+        actorDid: targetDid,
+      })
+    } else {
+      if (!starterPackWithMembership.listItem?.uri) {
+        console.error('Cannot remove: missing membership URI')
+        setIsPendingRefresh(false)
+        return
+      }
+      removeMembership({
+        listUri: listUri,
+        actorDid: targetDid,
+        membershipUri: starterPackWithMembership.listItem.uri,
+      })
+    }
+  }
+
+  const {record} = starterPack
+
+  if (
+    !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
+      record,
+      AppBskyGraphStarterpack.isRecord,
+    )
+  ) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
+      <View>
+        <Text emoji style={[a.text_md, a.font_bold]} numberOfLines={1}>
+          {record.name}
+        </Text>
+
+        <View style={[a.flex_row, a.align_center, a.mt_xs]}>
+          {starterPack.listItemsSample &&
+            starterPack.listItemsSample.length > 0 && (
+              <>
+                <AvatarStack
+                  size={32}
+                  profiles={starterPack.listItemsSample
+                    ?.slice(0, 4)
+                    .map(p => p.subject)}
+                />
+
+                {starterPack.list?.listItemCount &&
+                  starterPack.list.listItemCount > 4 && (
+                    <Text
+                      style={[
+                        a.text_sm,
+                        t.atoms.text_contrast_medium,
+                        a.ml_xs,
+                      ]}>
+                      <Trans>
+                        <Plural
+                          value={starterPack.list.listItemCount - 4}
+                          other="+# more"
+                        />
+                      </Trans>
+                    </Text>
+                  )}
+              </>
+            )}
+        </View>
+      </View>
+
+      <Button
+        label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
+        color={isInPack ? 'secondary' : 'primary_subtle'}
+        size="tiny"
+        disabled={isPendingRefresh}
+        onPress={handleToggleMembership}>
+        <ButtonText>
+          {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
+        </ButtonText>
+      </Button>
+    </View>
+  )
+}
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index dc1f78ef5..395c989b5 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -213,6 +213,7 @@ let MessageItem = ({
                 interactiveStyle={a.underline}
                 enableTags
                 emojiMultiplier={3}
+                shouldProxyLinks={true}
               />
             </View>
           )}
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index 9c3564aa5..bb9fde2e1 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {Pressable, View, type ViewStyle} from 'react-native'
+import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
 import Animated, {LinearTransition} from 'react-native-reanimated'
 
 import {HITSLOP_10} from '#/lib/constants'
@@ -59,6 +59,7 @@ export type GroupProps = React.PropsWithChildren<{
   disabled?: boolean
   onChange: (value: string[]) => void
   label: string
+  style?: StyleProp<ViewStyle>
 }>
 
 export type ItemProps = ViewStyleProp & {
@@ -84,6 +85,7 @@ export function Group({
   type = 'checkbox',
   maxSelections,
   label,
+  style,
 }: GroupProps) {
   const groupRole = type === 'radio' ? 'radiogroup' : undefined
   const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
@@ -136,7 +138,7 @@ export function Group({
   return (
     <GroupContext.Provider value={context}>
       <View
-        style={[a.w_full]}
+        style={[a.w_full, style]}
         role={groupRole}
         {...(groupRole === 'radiogroup'
           ? {
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx
index 549a1b9f0..79756c561 100644
--- a/src/components/moderation/ContentHider.tsx
+++ b/src/components/moderation/ContentHider.tsx
@@ -215,7 +215,7 @@ function ContentHiderActive({
             control.open()
           }}
           label={_(
-            msg`Learn more about the moderation applied to this content.`,
+            msg`Learn more about the moderation applied to this content`,
           )}
           style={[a.pt_sm]}>
           {state => (
diff --git a/src/components/verification/VerificationsDialog.tsx b/src/components/verification/VerificationsDialog.tsx
index 447e39e97..99ed00eeb 100644
--- a/src/components/verification/VerificationsDialog.tsx
+++ b/src/components/verification/VerificationsDialog.tsx
@@ -147,7 +147,12 @@ function Inner({
         <Link
           overridePresentation
           to={urls.website.blog.initialVerificationAnnouncement}
-          label={_(msg`Learn more about verification on Bluesky`)}
+          label={_(
+            msg({
+              message: `Learn more about verification on Bluesky`,
+              context: `english-only-resource`,
+            }),
+          )}
           size="small"
           variant="solid"
           color="secondary"
@@ -162,7 +167,7 @@ function Inner({
             )
           }}>
           <ButtonText>
-            <Trans>Learn more</Trans>
+            <Trans context="english-only-resource">Learn more</Trans>
           </ButtonText>
         </Link>
       </View>
diff --git a/src/components/verification/VerifierDialog.tsx b/src/components/verification/VerifierDialog.tsx
index c62f01832..b4756a85b 100644
--- a/src/components/verification/VerifierDialog.tsx
+++ b/src/components/verification/VerifierDialog.tsx
@@ -114,7 +114,12 @@ function Inner({
           <Link
             overridePresentation
             to={urls.website.blog.initialVerificationAnnouncement}
-            label={_(msg`Learn more about verification on Bluesky`)}
+            label={_(
+              msg({
+                message: `Learn more about verification on Bluesky`,
+                context: `english-only-resource`,
+              }),
+            )}
             size="small"
             variant="solid"
             color="primary"
@@ -129,7 +134,7 @@ function Inner({
               )
             }}>
             <ButtonText>
-              <Trans>Learn more</Trans>
+              <Trans context="english-only-resource">Learn more</Trans>
             </ButtonText>
           </Link>
           <Button