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>
This commit is contained in:
@@ -70,9 +70,21 @@ export const atoms = {
|
|||||||
overflow_visible: {
|
overflow_visible: {
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
},
|
},
|
||||||
|
overflow_x_visible: {
|
||||||
|
overflowX: 'visible',
|
||||||
|
},
|
||||||
|
overflow_y_visible: {
|
||||||
|
overflowY: 'visible',
|
||||||
|
},
|
||||||
overflow_hidden: {
|
overflow_hidden: {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
overflow_x_hidden: {
|
||||||
|
overflowX: 'hidden',
|
||||||
|
},
|
||||||
|
overflow_y_hidden: {
|
||||||
|
overflowY: 'hidden',
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* @platform web
|
* @platform web
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
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]}>
|
||||||
|
<DraggableScrollView
|
||||||
|
ref={listRef}
|
||||||
|
horizontal
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||||
import {
|
import {TextInput, useWindowDimensions, View} from 'react-native'
|
||||||
ScrollView,
|
|
||||||
type StyleProp,
|
|
||||||
TextInput,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
type ViewStyle,
|
|
||||||
} from 'react-native'
|
|
||||||
import {type ModerationOpts} from '@atproto/api'
|
import {type ModerationOpts} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
@@ -28,7 +20,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
atoms as a,
|
atoms as a,
|
||||||
native,
|
native,
|
||||||
tokens,
|
|
||||||
useBreakpoints,
|
useBreakpoints,
|
||||||
useTheme,
|
useTheme,
|
||||||
type ViewStyleProp,
|
type ViewStyleProp,
|
||||||
@@ -40,6 +31,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|||||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||||
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
|
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
@@ -337,12 +329,13 @@ let Header = ({
|
|||||||
}}
|
}}
|
||||||
onEscape={control.close}
|
onEscape={control.close}
|
||||||
/>
|
/>
|
||||||
<Tabs
|
<InterestTabs
|
||||||
onSelectTab={onSelectTab}
|
onSelectTab={onSelectTab}
|
||||||
interests={interests}
|
interests={interests}
|
||||||
selectedInterest={selectedInterest}
|
selectedInterest={selectedInterest}
|
||||||
hasSearchText={!!searchText}
|
disabled={!!searchText}
|
||||||
interestsDisplayNames={interestsDisplayNames}
|
interestsDisplayNames={interestsDisplayNames}
|
||||||
|
TabComponent={Tab}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</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 = ({
|
let Tab = ({
|
||||||
onSelectTab,
|
onSelectTab,
|
||||||
interest,
|
interest,
|
||||||
@@ -513,24 +413,36 @@ let Tab = ({
|
|||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
key={interest}
|
key={interest}
|
||||||
onLayout={e =>
|
onLayout={e =>
|
||||||
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
|
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
|
||||||
}>
|
}>
|
||||||
<Button
|
<Button label={label} onPress={() => onSelectTab(index)}>
|
||||||
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
|
{({hovered, pressed}) => (
|
||||||
onPress={() => onSelectTab(index)}>
|
|
||||||
{({hovered, pressed, focused}) => (
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_sm,
|
a.py_sm,
|
||||||
a.border,
|
a.border,
|
||||||
active || hovered || pressed || focused
|
active || hovered || pressed
|
||||||
? [
|
? [
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
|
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
|
||||||
@@ -540,7 +452,7 @@ let Tab = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.font_medium,
|
a.font_medium,
|
||||||
active || hovered || pressed || focused
|
active || hovered || pressed
|
||||||
? t.atoms.text
|
? t.atoms.text
|
||||||
: t.atoms.text_contrast_medium,
|
: t.atoms.text_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
@@ -759,13 +671,3 @@ function Empty({message}: {message: string}) {
|
|||||||
</View>
|
</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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slider = ref.current as unknown as HTMLDivElement
|
const slider = ref.current as unknown as HTMLDivElement
|
||||||
if (!slider) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let isDragging = false
|
let isDragging = false
|
||||||
let isMouseDown = false
|
let isMouseDown = false
|
||||||
let startX = 0
|
let startX = 0
|
||||||
@@ -61,6 +58,9 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const walk = x - startX
|
const walk = x - startX
|
||||||
slider.scrollLeft = scrollLeft - walk
|
slider.scrollLeft = scrollLeft - walk
|
||||||
|
|
||||||
|
if (slider.contains(document.activeElement))
|
||||||
|
(document.activeElement as HTMLElement)?.blur?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
slider.addEventListener('mousedown', mouseDown)
|
slider.addEventListener('mousedown', mouseDown)
|
||||||
|
|||||||
@@ -20,15 +20,14 @@ import {
|
|||||||
popularInterests,
|
popularInterests,
|
||||||
useInterestsDisplayNames,
|
useInterestsDisplayNames,
|
||||||
} from '#/screens/Onboarding/state'
|
} from '#/screens/Onboarding/state'
|
||||||
import {SuggestedAccountsTabBar} from '#/screens/Search/modules/ExploreSuggestedAccounts'
|
|
||||||
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
|
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
|
||||||
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||||
|
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {boostInterests} from '#/components/ProgressGuide/FollowDialog'
|
|
||||||
import * as toast from '#/components/Toast'
|
import * as toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
@@ -146,7 +145,7 @@ export function StepSuggestedAccounts() {
|
|||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.justify_start,
|
a.justify_start,
|
||||||
]}>
|
]}>
|
||||||
<SuggestedAccountsTabBar
|
<TabBar
|
||||||
selectedInterest={selectedInterest}
|
selectedInterest={selectedInterest}
|
||||||
onSelectInterest={setSelectedInterest}
|
onSelectInterest={setSelectedInterest}
|
||||||
defaultTabLabel={_(
|
defaultTabLabel={_(
|
||||||
@@ -155,8 +154,7 @@ export function StepSuggestedAccounts() {
|
|||||||
comment: 'the default tab in the interests tab bar',
|
comment: 'the default tab in the interests tab bar',
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
priorityInterests={state.interestsStepResults.selectedInterests}
|
selectedInterests={state.interestsStepResults.selectedInterests}
|
||||||
leftPadding={isWeb ? 0 : tokens.space.xl}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading || !moderationOpts ? (
|
{isLoading || !moderationOpts ? (
|
||||||
@@ -253,6 +251,52 @@ export function StepSuggestedAccounts() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TabBar({
|
||||||
|
selectedInterest,
|
||||||
|
onSelectInterest,
|
||||||
|
selectedInterests,
|
||||||
|
hideDefaultTab,
|
||||||
|
defaultTabLabel,
|
||||||
|
}: {
|
||||||
|
selectedInterest: string | null
|
||||||
|
onSelectInterest: (interest: string | null) => void
|
||||||
|
selectedInterests: string[]
|
||||||
|
hideDefaultTab?: boolean
|
||||||
|
defaultTabLabel?: string
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const interestsDisplayNames = useInterestsDisplayNames()
|
||||||
|
const interests = Object.keys(interestsDisplayNames)
|
||||||
|
.sort(boostInterests(popularInterests))
|
||||||
|
.sort(boostInterests(selectedInterests))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InterestTabs
|
||||||
|
interests={hideDefaultTab ? interests : ['all', ...interests]}
|
||||||
|
selectedInterest={
|
||||||
|
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
|
||||||
|
}
|
||||||
|
onSelectTab={tab => {
|
||||||
|
logger.metric(
|
||||||
|
'onboarding:suggestedAccounts:tabPressed',
|
||||||
|
{tab: tab},
|
||||||
|
{statsig: true},
|
||||||
|
)
|
||||||
|
onSelectInterest(tab === 'all' ? null : tab)
|
||||||
|
}}
|
||||||
|
interestsDisplayNames={
|
||||||
|
hideDefaultTab
|
||||||
|
? interestsDisplayNames
|
||||||
|
: {
|
||||||
|
all: defaultTabLabel || _(msg`For You`),
|
||||||
|
...interestsDisplayNames,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gutterWidth={isWeb ? 0 : tokens.space.xl}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SuggestedProfileCard({
|
function SuggestedProfileCard({
|
||||||
profile,
|
profile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ import {
|
|||||||
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
||||||
import {StarterPack} from '#/components/icons/StarterPack'
|
import {StarterPack} from '#/components/icons/StarterPack'
|
||||||
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
|
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
|
||||||
|
import {boostInterests} from '#/components/InterestTabs'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {boostInterests} from '#/components/ProgressGuide/FollowDialog'
|
|
||||||
import {SubtleHover} from '#/components/SubtleHover'
|
import {SubtleHover} from '#/components/SubtleHover'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import * as ModuleHeader from './components/ModuleHeader'
|
import * as ModuleHeader from './components/ModuleHeader'
|
||||||
@@ -751,7 +751,6 @@ export function Explore({
|
|||||||
selectedInterest={selectedInterest}
|
selectedInterest={selectedInterest}
|
||||||
onSelectInterest={setSelectedInterest}
|
onSelectInterest={setSelectedInterest}
|
||||||
hideDefaultTab={item.hideDefaultTab}
|
hideDefaultTab={item.hideDefaultTab}
|
||||||
logContext="Explore"
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ import {
|
|||||||
popularInterests,
|
popularInterests,
|
||||||
useInterestsDisplayNames,
|
useInterestsDisplayNames,
|
||||||
} from '#/screens/Onboarding/state'
|
} from '#/screens/Onboarding/state'
|
||||||
import {tokens, useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
|
|
||||||
import {SubtleHover} from '#/components/SubtleHover'
|
import {SubtleHover} from '#/components/SubtleHover'
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function useLoadEnoughProfiles({
|
export function useLoadEnoughProfiles({
|
||||||
@@ -55,23 +53,16 @@ export function useLoadEnoughProfiles({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move to own file -sfn
|
|
||||||
export function SuggestedAccountsTabBar({
|
export function SuggestedAccountsTabBar({
|
||||||
selectedInterest,
|
selectedInterest,
|
||||||
onSelectInterest,
|
onSelectInterest,
|
||||||
hideDefaultTab,
|
hideDefaultTab,
|
||||||
defaultTabLabel,
|
defaultTabLabel,
|
||||||
priorityInterests,
|
|
||||||
leftPadding = tokens.space.md,
|
|
||||||
logContext = 'Explore',
|
|
||||||
}: {
|
}: {
|
||||||
selectedInterest: string | null
|
selectedInterest: string | null
|
||||||
onSelectInterest: (interest: string | null) => void
|
onSelectInterest: (interest: string | null) => void
|
||||||
priorityInterests?: string[]
|
|
||||||
hideDefaultTab?: boolean
|
hideDefaultTab?: boolean
|
||||||
defaultTabLabel?: string
|
defaultTabLabel?: string
|
||||||
leftPadding?: number
|
|
||||||
logContext?: 'Explore' | 'Onboarding'
|
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const interestsDisplayNames = useInterestsDisplayNames()
|
const interestsDisplayNames = useInterestsDisplayNames()
|
||||||
@@ -80,32 +71,22 @@ export function SuggestedAccountsTabBar({
|
|||||||
const interests = Object.keys(interestsDisplayNames)
|
const interests = Object.keys(interestsDisplayNames)
|
||||||
.sort(boostInterests(popularInterests))
|
.sort(boostInterests(popularInterests))
|
||||||
.sort(boostInterests(personalizedInterests))
|
.sort(boostInterests(personalizedInterests))
|
||||||
.sort(boostInterests(priorityInterests))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlockDrawerGesture>
|
<BlockDrawerGesture>
|
||||||
<Tabs
|
<InterestTabs
|
||||||
interests={hideDefaultTab ? interests : ['all', ...interests]}
|
interests={hideDefaultTab ? interests : ['all', ...interests]}
|
||||||
selectedInterest={
|
selectedInterest={
|
||||||
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
|
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
|
||||||
}
|
}
|
||||||
onSelectTab={tab => {
|
onSelectTab={tab => {
|
||||||
if (logContext === 'Explore') {
|
|
||||||
logger.metric(
|
logger.metric(
|
||||||
'explore:suggestedAccounts:tabPressed',
|
'explore:suggestedAccounts:tabPressed',
|
||||||
{tab: tab},
|
{tab: tab},
|
||||||
{statsig: true},
|
{statsig: true},
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
logger.metric(
|
|
||||||
'onboarding:suggestedAccounts:tabPressed',
|
|
||||||
{tab: tab},
|
|
||||||
{statsig: true},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelectInterest(tab === 'all' ? null : tab)
|
onSelectInterest(tab === 'all' ? null : tab)
|
||||||
}}
|
}}
|
||||||
hasSearchText={false}
|
|
||||||
interestsDisplayNames={
|
interestsDisplayNames={
|
||||||
hideDefaultTab
|
hideDefaultTab
|
||||||
? interestsDisplayNames
|
? interestsDisplayNames
|
||||||
@@ -114,73 +95,11 @@ export function SuggestedAccountsTabBar({
|
|||||||
...interestsDisplayNames,
|
...interestsDisplayNames,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TabComponent={Tab}
|
|
||||||
contentContainerStyle={[
|
|
||||||
{
|
|
||||||
// visual alignment
|
|
||||||
paddingLeft: leftPadding,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</BlockDrawerGesture>
|
</BlockDrawerGesture>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let 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
|
|
||||||
}): React.ReactNode => {
|
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const activeText = active ? _(msg` (active)`) : ''
|
|
||||||
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}) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.rounded_full,
|
|
||||||
a.px_lg,
|
|
||||||
a.py_sm,
|
|
||||||
a.border,
|
|
||||||
active || hovered || pressed || focused
|
|
||||||
? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
|
|
||||||
: [t.atoms.bg, t.atoms.border_contrast_low],
|
|
||||||
]}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.font_medium,
|
|
||||||
active || hovered || pressed || focused
|
|
||||||
? t.atoms.text
|
|
||||||
: t.atoms.text_contrast_medium,
|
|
||||||
]}>
|
|
||||||
{interestsDisplayName}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Tab = memo(Tab)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile card for suggested accounts. Note: border is on the bottom edge
|
* Profile card for suggested accounts. Note: border is on the bottom edge
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user