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