diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Dialog/index.tsx | 61 | ||||
-rw-r--r-- | src/components/Dialog/index.web.tsx | 38 | ||||
-rw-r--r-- | src/components/InterestTabs.tsx | 390 | ||||
-rw-r--r-- | src/components/LoggedOutCTA.tsx | 6 | ||||
-rw-r--r-- | src/components/ProgressGuide/FollowDialog.tsx | 146 | ||||
-rw-r--r-- | src/components/Toast/Toast.tsx | 91 | ||||
-rw-r--r-- | src/components/Toast/index.e2e.tsx | 8 | ||||
-rw-r--r-- | src/components/Toast/index.tsx | 40 | ||||
-rw-r--r-- | src/components/Toast/index.web.tsx | 30 | ||||
-rw-r--r-- | src/components/dialogs/StarterPackDialog.tsx | 46 | ||||
-rw-r--r-- | src/components/forms/Toggle.tsx | 6 |
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' ? { |