about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-08-27 14:17:45 +0300
committerGitHub <noreply@github.com>2025-08-27 04:17:45 -0700
commiteac02901435d7bc79a28e0bff665352b814f9508 (patch)
tree8d770830a0c7081c5e4ed941b192da34ecb538a8 /src/components
parent0617fca5ef1d30e7db49eb7dc8e66f6b586cc207 (diff)
downloadvoidsky-eac02901435d7bc79a28e0bff665352b814f9508.tar.zst
Add suggested follows experiment to onboarding (#8847)
* add new gated screen to onboarding

* add tab bar, adjust layout

* replace chevron with arrow

* get suggested accounts working on native

* tweaks for web

* add metrics to follow all

* rm non-functional link from card

* ensure selected interests are passed through to interests query

* fix logcontext

* followed all accounts! toast

* rm save interests function

* Update src/screens/Onboarding/StepSuggestedAccounts/index.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* use admonition

* rm comment

* Better interest tabs (#8879)

* make tabs draggable

* move tab component to own file

* rm focused state from tab, improve label

* add focus styles, remove focus when dragging

* rm log

* add arrows to tabs

* rename Tabs -> InterestTabs

* try and simplify approach

* rename ref

* Update InterestTabs.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/InterestTabs.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/ProgressGuide/FollowDialog.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/ProgressGuide/FollowDialog.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add newline

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix flex problem

* Add value proposition screen experiment (#8898)

* add assets

* add value prop experiment

* add alt text

* add metrics

* add transitions

* add skip button

* tweak copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add borderless variant for web

* rm pointer events

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Add slight delay, prevent layout shift

* Handle layout shift, add Let's Go! text

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src/components')
-rw-r--r--src/components/InterestTabs.tsx390
-rw-r--r--src/components/ProgressGuide/FollowDialog.tsx146
2 files changed, 414 insertions, 122 deletions
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/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
-  }
-}