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.tsx38
-rw-r--r--src/components/InterestTabs.tsx390
-rw-r--r--src/components/LoggedOutCTA.tsx6
-rw-r--r--src/components/ProgressGuide/FollowDialog.tsx146
-rw-r--r--src/components/Toast/Toast.tsx91
-rw-r--r--src/components/Toast/index.e2e.tsx8
-rw-r--r--src/components/Toast/index.tsx40
-rw-r--r--src/components/Toast/index.web.tsx30
-rw-r--r--src/components/dialogs/StarterPackDialog.tsx46
-rw-r--r--src/components/forms/Toggle.tsx6
11 files changed, 607 insertions, 255 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 7e10dfadc..1d62cbfdc 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -33,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()
 
@@ -215,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()
@@ -227,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]}>
@@ -237,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/InterestTabs.tsx b/src/components/InterestTabs.tsx
new file mode 100644
index 000000000..b61157ed8
--- /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 = 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
index 7ec8c2264..0bafbd45f 100644
--- a/src/components/LoggedOutCTA.tsx
+++ b/src/components/LoggedOutCTA.tsx
@@ -1,5 +1,6 @@
 import {View, type ViewStyle} from 'react-native'
-import {Trans} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 import {type Gate} from '#/lib/statsig/gates'
 import {useGate} from '#/lib/statsig/statsig'
@@ -21,6 +22,7 @@ export function LoggedOutCTA({style, gateName}: LoggedOutCTAProps) {
   const {requestSwitchToAccount} = useLoggedOutViewControls()
   const gate = useGate()
   const t = useTheme()
+  const {_} = useLingui()
 
   // Only show for logged-out users on web
   if (hasSession || !isWeb) {
@@ -66,7 +68,7 @@ export function LoggedOutCTA({style, gateName}: LoggedOutCTAProps) {
           onPress={() => {
             requestSwitchToAccount({requestedAccount: 'new'})
           }}
-          label="Create account"
+          label={_(msg`Create account`)}
           size="small"
           variant="solid"
           color="primary">
diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx
index 20ebb0abf..a2ec4df13 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/Toast/Toast.tsx b/src/components/Toast/Toast.tsx
index 53d5e5115..ac5bc4889 100644
--- a/src/components/Toast/Toast.tsx
+++ b/src/components/Toast/Toast.tsx
@@ -16,19 +16,6 @@ import {dismiss} from '#/components/Toast/sonner'
 import {type ToastType} from '#/components/Toast/types'
 import {Text as BaseText} from '#/components/Typography'
 
-type ToastConfigContextType = {
-  id: string
-}
-
-type ToastThemeContextType = {
-  type: ToastType
-}
-
-export type ToastComponentProps = {
-  type?: ToastType
-  content: string
-}
-
 export const ICONS = {
   default: CircleCheck,
   success: CircleCheck,
@@ -37,81 +24,67 @@ export const ICONS = {
   info: CircleInfo,
 }
 
-const ToastConfigContext = createContext<ToastConfigContextType>({
+const ToastConfigContext = createContext<{
+  id: string
+  type: ToastType
+}>({
   id: '',
+  type: 'default',
 })
 ToastConfigContext.displayName = 'ToastConfigContext'
 
 export function ToastConfigProvider({
   children,
   id,
+  type,
 }: {
   children: React.ReactNode
   id: string
+  type: ToastType
 }) {
   return (
-    <ToastConfigContext.Provider value={useMemo(() => ({id}), [id])}>
+    <ToastConfigContext.Provider
+      value={useMemo(() => ({id, type}), [id, type])}>
       {children}
     </ToastConfigContext.Provider>
   )
 }
 
-const ToastThemeContext = createContext<ToastThemeContextType>({
-  type: 'default',
-})
-ToastThemeContext.displayName = 'ToastThemeContext'
-
-export function Default({type = 'default', content}: ToastComponentProps) {
-  return (
-    <Outer type={type}>
-      <Icon />
-      <Text>{content}</Text>
-    </Outer>
-  )
-}
-
-export function Outer({
-  children,
-  type = 'default',
-}: {
-  children: React.ReactNode
-  type?: ToastType
-}) {
+export function Outer({children}: {children: React.ReactNode}) {
   const t = useTheme()
+  const {type} = useContext(ToastConfigContext)
   const styles = useToastStyles({type})
 
   return (
-    <ToastThemeContext.Provider value={useMemo(() => ({type}), [type])}>
-      <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>
-    </ToastThemeContext.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 Icon({icon}: {icon?: React.ComponentType<SVGIconProps>}) {
-  const {type} = useContext(ToastThemeContext)
+  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(ToastThemeContext)
+  const {type} = useContext(ToastConfigContext)
   const {textColor} = useToastStyles({type})
   const {fontScaleCompensation} = useToastFontScaleCompensation()
   return (
@@ -142,12 +115,12 @@ export function Text({children}: {children: React.ReactNode}) {
 
 export function Action(
   props: Omit<ButtonProps, UninheritableButtonProps | 'children'> & {
-    children: string
+    children: React.ReactNode
   },
 ) {
   const t = useTheme()
   const {fontScaleCompensation} = useToastFontScaleCompensation()
-  const {type} = useContext(ToastThemeContext)
+  const {type} = useContext(ToastConfigContext)
   const {id} = useContext(ToastConfigContext)
   const styles = useMemo(() => {
     const base = {
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 0d1c661d2..d70a8ad16 100644
--- a/src/components/Toast/index.tsx
+++ b/src/components/Toast/index.tsx
@@ -6,15 +6,15 @@ import {toast as sonner, Toaster} from 'sonner-native'
 import {atoms as a} from '#/alf'
 import {DURATION} from '#/components/Toast/const'
 import {
-  Default as DefaultToast,
+  Icon as ToastIcon,
   Outer as BaseOuter,
-  type ToastComponentProps,
+  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} from '#/components/Toast/Toast'
+export {Action, Icon, Text, ToastConfigProvider} from '#/components/Toast/Toast'
 export {type ToastType} from '#/components/Toast/types'
 
 /**
@@ -25,27 +25,10 @@ export function ToastOutlet() {
   return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
 }
 
-/**
- * The toast UI component
- */
-export function Default({type, content}: ToastComponentProps) {
+export function Outer({children}: {children: React.ReactNode}) {
   return (
     <View style={[a.px_xl, a.w_full]}>
-      <DefaultToast content={content} type={type} />
-    </View>
-  )
-}
-
-export function Outer({
-  children,
-  type = 'default',
-}: {
-  children: React.ReactNode
-  type?: ToastComponentProps['type']
-}) {
-  return (
-    <View style={[a.px_xl, a.w_full]}>
-      <BaseOuter type={type}>{children}</BaseOuter>
+      <BaseOuter>{children}</BaseOuter>
     </View>
   )
 }
@@ -60,14 +43,17 @@ export const api = sonner
  */
 export function show(
   content: React.ReactNode,
-  {type, ...options}: BaseToastOptions = {},
+  {type = 'default', ...options}: BaseToastOptions = {},
 ) {
   const id = nanoid()
 
   if (typeof content === 'string') {
     sonner.custom(
-      <ToastConfigProvider id={id}>
-        <Default content={content} type={type} />
+      <ToastConfigProvider id={id} type={type}>
+        <Outer>
+          <ToastIcon />
+          <ToastText>{content}</ToastText>
+        </Outer>
       </ToastConfigProvider>,
       {
         ...options,
@@ -77,7 +63,9 @@ export function show(
     )
   } else if (React.isValidElement(content)) {
     sonner.custom(
-      <ToastConfigProvider id={id}>{content}</ToastConfigProvider>,
+      <ToastConfigProvider id={id} type={type}>
+        {content}
+      </ToastConfigProvider>,
       {
         ...options,
         id,
diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx
index c4db20dca..8b2028db9 100644
--- a/src/components/Toast/index.web.tsx
+++ b/src/components/Toast/index.web.tsx
@@ -5,7 +5,9 @@ import {toast as sonner, Toaster} from 'sonner'
 import {atoms as a} from '#/alf'
 import {DURATION} from '#/components/Toast/const'
 import {
-  Default as DefaultToast,
+  Icon as ToastIcon,
+  Outer as ToastOuter,
+  Text as ToastText,
   ToastConfigProvider,
 } from '#/components/Toast/Toast'
 import {type BaseToastOptions} from '#/components/Toast/types'
@@ -39,14 +41,17 @@ export const api = sonner
  */
 export function show(
   content: React.ReactNode,
-  {type, ...options}: BaseToastOptions = {},
+  {type = 'default', ...options}: BaseToastOptions = {},
 ) {
   const id = nanoid()
 
   if (typeof content === 'string') {
     sonner(
-      <ToastConfigProvider id={id}>
-        <DefaultToast content={content} type={type} />
+      <ToastConfigProvider id={id} type={type}>
+        <ToastOuter>
+          <ToastIcon />
+          <ToastText>{content}</ToastText>
+        </ToastOuter>
       </ToastConfigProvider>,
       {
         ...options,
@@ -56,12 +61,17 @@ export function show(
       },
     )
   } else if (React.isValidElement(content)) {
-    sonner(<ToastConfigProvider id={id}>{content}</ToastConfigProvider>, {
-      ...options,
-      unstyled: true, // required on web
-      id,
-      duration: options?.duration ?? DURATION,
-    })
+    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/dialogs/StarterPackDialog.tsx b/src/components/dialogs/StarterPackDialog.tsx
index ec041d401..c4b8a72c4 100644
--- a/src/components/dialogs/StarterPackDialog.tsx
+++ b/src/components/dialogs/StarterPackDialog.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {useCallback, useState} from 'react'
 import {View} from 'react-native'
 import {
   type AppBskyGraphGetStarterPacksWithMembership,
@@ -22,16 +22,16 @@ import {
 } 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'
-import {AvatarStack} from '../AvatarStack'
-import {PlusLarge_Stroke2_Corner0_Rounded} from '../icons/Plus'
-import {StarterPack} from '../icons/StarterPack'
-import {TimesLarge_Stroke2_Corner0_Rounded} from '../icons/Times'
 
 type StarterPackWithMembership =
   AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership
@@ -51,7 +51,7 @@ export function StarterPackDialog({
   const navigation = useNavigation<NavigationProp>()
   const requireEmailVerification = useRequireEmailVerification()
 
-  const navToWizard = React.useCallback(() => {
+  const navToWizard = useCallback(() => {
     control.close()
     navigation.navigate('StarterPackWizard', {
       fromDialog: true,
@@ -91,7 +91,6 @@ function Empty({onStartWizard}: {onStartWizard: () => void}) {
   const {_} = useLingui()
   const t = useTheme()
 
-  isWeb
   return (
     <View style={[a.gap_2xl, {paddingTop: isWeb ? 100 : 64}]}>
       <View style={[a.gap_xs, a.align_center]}>
@@ -115,7 +114,7 @@ function Empty({onStartWizard}: {onStartWizard: () => void}) {
               Create
             </Trans>
           </ButtonText>
-          <ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} />
+          <ButtonIcon icon={PlusIcon} />
         </Button>
       </View>
     </View>
@@ -134,7 +133,6 @@ function StarterPackList({
   enabled?: boolean
 }) {
   const {_} = useLingui()
-  const t = useTheme()
 
   const {
     data,
@@ -149,7 +147,7 @@ function StarterPackList({
   const membershipItems =
     data?.pages.flatMap(page => page.starterPacksWithMembership) || []
 
-  const _onRefresh = React.useCallback(async () => {
+  const _onRefresh = useCallback(async () => {
     try {
       await refetch()
     } catch (err) {
@@ -157,7 +155,7 @@ function StarterPackList({
     }
   }, [refetch])
 
-  const _onEndReached = React.useCallback(async () => {
+  const _onEndReached = useCallback(async () => {
     if (isFetchingNextPage || !hasNextPage || isError) return
     try {
       await fetchNextPage()
@@ -166,25 +164,17 @@ function StarterPackList({
     }
   }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
 
-  const renderItem = React.useCallback(
+  const renderItem = useCallback(
     ({item}: {item: StarterPackWithMembership}) => (
       <StarterPackItem starterPackWithMembership={item} targetDid={targetDid} />
     ),
     [targetDid],
   )
 
-  const onClose = React.useCallback(() => {
+  const onClose = useCallback(() => {
     control.close()
   }, [control])
 
-  const XIcon = React.useMemo(() => {
-    return (
-      <TimesLarge_Stroke2_Corner0_Rounded
-        fill={t.atoms.text_contrast_medium.color}
-      />
-    )
-  }, [t])
-
   const listHeader = (
     <>
       <View
@@ -196,8 +186,14 @@ function StarterPackList({
         <Text style={[a.text_lg, a.font_bold]}>
           <Trans>Add to starter packs</Trans>
         </Text>
-        <Button label={_(msg`Close`)} onPress={onClose}>
-          <ButtonIcon icon={() => XIcon} />
+        <Button
+          label={_(msg`Close`)}
+          onPress={onClose}
+          variant="ghost"
+          color="secondary"
+          size="small"
+          shape="round">
+          <ButtonIcon icon={XIcon} />
         </Button>
       </View>
       {membershipItems.length > 0 && (
@@ -217,7 +213,7 @@ function StarterPackList({
                   Create
                 </Trans>
               </ButtonText>
-              <ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} />
+              <ButtonIcon icon={PlusIcon} />
             </Button>
           </View>
           <Divider />
@@ -268,7 +264,7 @@ function StarterPackItem({
   const starterPack = starterPackWithMembership.starterPack
   const isInPack = !!starterPackWithMembership.listItem
 
-  const [isPendingRefresh, setIsPendingRefresh] = React.useState(false)
+  const [isPendingRefresh, setIsPendingRefresh] = useState(false)
 
   const {mutate: addMembership} = useListMembershipAddMutation({
     onSuccess: () => {
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'
           ? {