diff options
Diffstat (limited to 'src/components')
89 files changed, 5760 insertions, 526 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 68cee4374..ece1ad6b0 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,20 +1,22 @@ import React from 'react' import { + AccessibilityProps, Pressable, - Text, PressableProps, + StyleProp, + StyleSheet, + Text, TextProps, - ViewStyle, - AccessibilityProps, - View, TextStyle, - StyleSheet, - StyleProp, + View, + ViewStyle, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' +import {Trans} from '@lingui/macro' -import {useTheme, atoms as a, tokens, android, flatten} from '#/alf' +import {android, atoms as a, flatten, tokens, useTheme} from '#/alf' import {Props as SVGIconProps} from '#/components/icons/common' +import {normalizeTextStyles} from '#/components/Typography' export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' export type ButtonColor = @@ -27,7 +29,7 @@ export type ButtonColor = | 'gradient_sunset' | 'gradient_nordic' | 'gradient_bonfire' -export type ButtonSize = 'small' | 'large' +export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large' export type ButtonShape = 'round' | 'square' | 'default' export type VariantProps = { /** @@ -48,25 +50,32 @@ export type VariantProps = { shape?: ButtonShape } -export type ButtonProps = React.PropsWithChildren< - Pick<PressableProps, 'disabled' | 'onPress'> & - AccessibilityProps & - VariantProps & { - testID?: string - label: string - style?: StyleProp<ViewStyle> - } -> -export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} +export type ButtonState = { + hovered: boolean + focused: boolean + pressed: boolean + disabled: boolean +} -const Context = React.createContext< +export type ButtonContext = VariantProps & ButtonState + +export type ButtonProps = Pick< + PressableProps, + 'disabled' | 'onPress' | 'testID' +> & + AccessibilityProps & VariantProps & { - hovered: boolean - focused: boolean - pressed: boolean - disabled: boolean + testID?: string + label: string + style?: StyleProp<ViewStyle> + children: + | React.ReactNode + | string + | ((context: ButtonContext) => React.ReactNode | string) } ->({ +export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} + +const Context = React.createContext<VariantProps & ButtonState>({ hovered: false, focused: false, pressed: false, @@ -132,7 +141,7 @@ export function Button({ })) }, [setState]) - const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => { + const {baseStyles, hoverStyles} = React.useMemo(() => { const baseStyles: ViewStyle[] = [] const hoverStyles: ViewStyle[] = [] const light = t.name === 'light' @@ -158,7 +167,7 @@ export function Button({ if (!disabled) { baseStyles.push(a.border, { - borderColor: tokens.color.blue_500, + borderColor: t.palette.primary_500, }) hoverStyles.push(a.border, { backgroundColor: light @@ -167,7 +176,7 @@ export function Button({ }) } else { baseStyles.push(a.border, { - borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900, + borderColor: light ? t.palette.primary_200 : t.palette.primary_900, }) } } else if (variant === 'ghost') { @@ -184,20 +193,14 @@ export function Button({ if (variant === 'solid') { if (!disabled) { baseStyles.push({ - backgroundColor: light - ? tokens.color.gray_50 - : tokens.color.gray_900, + backgroundColor: t.palette.contrast_25, }) hoverStyles.push({ - backgroundColor: light - ? tokens.color.gray_100 - : tokens.color.gray_950, + backgroundColor: t.palette.contrast_50, }) } else { baseStyles.push({ - backgroundColor: light - ? tokens.color.gray_200 - : tokens.color.gray_950, + backgroundColor: t.palette.contrast_100, }) } } else if (variant === 'outline') { @@ -207,21 +210,19 @@ export function Button({ if (!disabled) { baseStyles.push(a.border, { - borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700, + borderColor: t.palette.contrast_300, }) - hoverStyles.push(a.border, t.atoms.bg_contrast_50) + hoverStyles.push(t.atoms.bg_contrast_50) } else { baseStyles.push(a.border, { - borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800, + borderColor: t.palette.contrast_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ - backgroundColor: light - ? tokens.color.gray_100 - : tokens.color.gray_900, + backgroundColor: t.palette.contrast_100, }) } } @@ -229,14 +230,14 @@ export function Button({ if (variant === 'solid') { if (!disabled) { baseStyles.push({ - backgroundColor: t.palette.negative_400, + backgroundColor: t.palette.negative_500, }) hoverStyles.push({ - backgroundColor: t.palette.negative_500, + backgroundColor: t.palette.negative_600, }) } else { baseStyles.push({ - backgroundColor: t.palette.negative_600, + backgroundColor: t.palette.negative_700, }) } } else if (variant === 'outline') { @@ -246,7 +247,7 @@ export function Button({ if (!disabled) { baseStyles.push(a.border, { - borderColor: t.palette.negative_400, + borderColor: t.palette.negative_500, }) hoverStyles.push(a.border, { backgroundColor: light @@ -266,7 +267,7 @@ export function Button({ hoverStyles.push({ backgroundColor: light ? t.palette.negative_100 - : t.palette.negative_950, + : t.palette.negative_975, }) } } @@ -275,8 +276,12 @@ export function Button({ if (shape === 'default') { if (size === 'large') { baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) + } else if (size === 'medium') { + baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md) } else if (size === 'small') { baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) + } else if (size === 'tiny') { + baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs) } } else if (shape === 'round' || shape === 'square') { if (size === 'large') { @@ -287,24 +292,24 @@ export function Button({ } } else if (size === 'small') { baseStyles.push({height: 40, width: 40}) + } else if (size === 'tiny') { + baseStyles.push({height: 20, width: 20}) } if (shape === 'round') { baseStyles.push(a.rounded_full) } else if (shape === 'square') { - baseStyles.push(a.rounded_sm) + if (size === 'tiny') { + baseStyles.push(a.rounded_xs) + } else { + baseStyles.push(a.rounded_sm) + } } } return { baseStyles, hoverStyles, - focusStyles: [ - ...hoverStyles, - { - outline: 0, - } as ViewStyle, - ], } }, [t, variant, color, size, shape, disabled]) @@ -338,7 +343,7 @@ export function Button({ } }, [variant, color]) - const context = React.useMemo( + const context = React.useMemo<ButtonContext>( () => ({ ...state, variant, @@ -349,6 +354,8 @@ export function Button({ [state, variant, color, size, disabled], ) + const flattenedBaseStyles = flatten(baseStyles) + return ( <Pressable role="button" @@ -362,15 +369,12 @@ export function Button({ disabled: disabled || false, }} style={[ - flatten(style), a.flex_row, a.align_center, a.justify_center, - a.overflow_hidden, - a.justify_center, - ...baseStyles, + flattenedBaseStyles, ...(state.hovered || state.pressed ? hoverStyles : []), - ...(state.focused ? focusStyles : []), + flatten(style), ]} onPressIn={onPressIn} onPressOut={onPressOut} @@ -379,21 +383,33 @@ export function Button({ onFocus={onFocus} onBlur={onBlur}> {variant === 'gradient' && ( - <LinearGradient - colors={ - state.hovered || state.pressed || state.focused - ? gradientHoverColors - : gradientColors - } - locations={gradientLocations} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[a.absolute, a.inset_0]} - /> + <View + style={[ + a.absolute, + a.inset_0, + a.overflow_hidden, + {borderRadius: flattenedBaseStyles.borderRadius}, + ]}> + <LinearGradient + colors={ + state.hovered || state.pressed + ? gradientHoverColors + : gradientColors + } + locations={gradientLocations} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[a.absolute, a.inset_0]} + /> + </View> )} <Context.Provider value={context}> - {typeof children === 'string' ? ( + {/* @ts-ignore */} + {typeof children === 'string' || children?.type === Trans ? ( + /* @ts-ignore */ <ButtonText>{children}</ButtonText> + ) : typeof children === 'function' ? ( + children(context) ) : ( children )} @@ -435,31 +451,31 @@ export function useSharedButtonTextStyles() { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({ - color: light ? tokens.color.gray_700 : tokens.color.gray_100, + color: t.palette.contrast_700, }) } else { baseStyles.push({ - color: light ? tokens.color.gray_400 : tokens.color.gray_700, + color: t.palette.contrast_400, }) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({ - color: light ? tokens.color.gray_600 : tokens.color.gray_300, + color: t.palette.contrast_600, }) } else { baseStyles.push({ - color: light ? tokens.color.gray_400 : tokens.color.gray_700, + color: t.palette.contrast_300, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({ - color: light ? tokens.color.gray_600 : tokens.color.gray_300, + color: t.palette.contrast_600, }) } else { baseStyles.push({ - color: light ? tokens.color.gray_400 : tokens.color.gray_600, + color: t.palette.contrast_300, }) } } @@ -493,6 +509,8 @@ export function useSharedButtonTextStyles() { if (size === 'large') { baseStyles.push(a.text_md, android({paddingBottom: 1})) + } else if (size === 'tiny') { + baseStyles.push(a.text_xs, android({paddingBottom: 1})) } else { baseStyles.push(a.text_sm, android({paddingBottom: 1})) } @@ -505,7 +523,14 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) { const textStyles = useSharedButtonTextStyles() return ( - <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}> + <Text + {...rest} + style={normalizeTextStyles([ + a.font_bold, + a.text_center, + textStyles, + style, + ])}> {children} </Text> ) @@ -514,9 +539,11 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) { export function ButtonIcon({ icon: Comp, position, + size: iconSize, }: { icon: React.ComponentType<SVGIconProps> position?: 'left' | 'right' + size?: SVGIconProps['size'] }) { const {size, disabled} = useButtonContext() const textStyles = useSharedButtonTextStyles() @@ -532,7 +559,9 @@ export function ButtonIcon({ }, ]}> <Comp - size={size === 'large' ? 'md' : 'sm'} + size={ + iconSize ?? (size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm') + } style={[{color: textStyles.color, pointerEvents: 'none'}]} /> </View> diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index b28b9f5a2..859f8edd7 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -1,7 +1,11 @@ import React from 'react' import {useDialogStateContext} from '#/state/dialogs' -import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types' +import { + DialogContextProps, + DialogControlRefProps, + DialogOuterProps, +} from '#/components/Dialog/types' export const Context = React.createContext<DialogContextProps>({ close: () => {}, @@ -11,9 +15,9 @@ export function useDialogContext() { return React.useContext(Context) } -export function useDialogControl() { +export function useDialogControl(): DialogOuterProps['control'] { const id = React.useId() - const control = React.useRef<DialogControlProps>({ + const control = React.useRef<DialogControlRefProps>({ open: () => {}, close: () => {}, }) @@ -27,9 +31,17 @@ export function useDialogControl() { } }, [id, activeDialogs]) - return { - ref: control, - open: () => control.current.open(), - close: () => control.current.close(), - } + return React.useMemo<DialogOuterProps['control']>( + () => ({ + id, + ref: control, + open: () => { + control.current.open() + }, + close: cb => { + control.current.close(cb) + }, + }), + [id, control], + ) } diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 9132e68de..07e101f85 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,46 +1,114 @@ import React, {useImperativeHandle} from 'react' -import {View, Dimensions} from 'react-native' +import {Dimensions, Pressable, View} from 'react-native' +import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import BottomSheet, { - BottomSheetBackdrop, + BottomSheetBackdropProps, BottomSheetScrollView, + BottomSheetScrollViewMethods, BottomSheetTextInput, BottomSheetView, -} from '@gorhom/bottom-sheet' -import {useSafeAreaInsets} from 'react-native-safe-area-context' - -import {useTheme, atoms as a} from '#/alf' -import {Portal} from '#/components/Portal' -import {createInput} from '#/components/forms/TextField' + useBottomSheet, + WINDOW_HEIGHT, +} from '@discord/bottom-sheet/src' +import {logger} from '#/logger' +import {useDialogStateControlContext} from '#/state/dialogs' +import {isNative} from 'platform/detection' +import {atoms as a, flatten, useTheme} from '#/alf' +import {Context} from '#/components/Dialog/context' import { - DialogOuterProps, DialogControlProps, DialogInnerProps, + DialogOuterProps, } from '#/components/Dialog/types' -import {Context} from '#/components/Dialog/context' +import {createInput} from '#/components/forms/TextField' +import {Portal} from '#/components/Portal' -export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export * from '#/components/Dialog/types' // @ts-ignore export const Input = createInput(BottomSheetTextInput) +function Backdrop(props: BottomSheetBackdropProps) { + const t = useTheme() + const bottomSheet = useBottomSheet() + + const animatedStyle = useAnimatedStyle(() => { + const opacity = + (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000 + + return { + opacity: Math.min(Math.max(opacity, 0), 0.55), + } + }) + + const onPress = React.useCallback(() => { + bottomSheet.close() + }, [bottomSheet]) + + return ( + <Animated.View + style={[ + t.atoms.bg_contrast_300, + { + top: 0, + left: 0, + right: 0, + bottom: 0, + position: 'absolute', + }, + animatedStyle, + ]}> + <Pressable + accessibilityRole="button" + accessibilityLabel="Dialog backdrop" + accessibilityHint="Press the backdrop to close the dialog" + style={{flex: 1}} + onPress={onPress} + /> + </Animated.View> + ) +} + export function Outer({ children, control, onClose, nativeOptions, + testID, }: React.PropsWithChildren<DialogOuterProps>) { const t = useTheme() const sheet = React.useRef<BottomSheet>(null) const sheetOptions = nativeOptions?.sheet || {} const hasSnapPoints = !!sheetOptions.snapPoints const insets = useSafeAreaInsets() + const closeCallback = React.useRef<() => void>() + const {setDialogIsOpen} = useDialogStateControlContext() - const open = React.useCallback<DialogControlProps['open']>((i = 0) => { - sheet.current?.snapToIndex(i) - }, []) + /* + * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet` + */ + const [openIndex, setOpenIndex] = React.useState(-1) + + /* + * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open. + */ + const isOpen = openIndex > -1 - const close = React.useCallback(() => { + const open = React.useCallback<DialogControlProps['open']>( + ({index} = {}) => { + setDialogIsOpen(control.id, true) + // can be set to any index of `snapPoints`, but `0` is the first i.e. "open" + setOpenIndex(index || 0) + }, + [setOpenIndex, setDialogIsOpen, control.id], + ) + + const close = React.useCallback<DialogControlProps['close']>(cb => { + if (cb && typeof cb === 'function') { + closeCallback.current = cb + } sheet.current?.close() }, []) @@ -53,103 +121,120 @@ export function Outer({ [open, close], ) - const onChange = React.useCallback( - (index: number) => { - if (index === -1) { - onClose?.() - } - }, - [onClose], - ) + const onCloseInner = React.useCallback(() => { + try { + closeCallback.current?.() + } catch (e: any) { + logger.error(`Dialog closeCallback failed`, { + message: e.message, + }) + } finally { + closeCallback.current = undefined + } + setDialogIsOpen(control.id, false) + onClose?.() + setOpenIndex(-1) + }, [control.id, onClose, setDialogIsOpen]) const context = React.useMemo(() => ({close}), [close]) return ( - <Portal> - <BottomSheet - enableDynamicSizing={!hasSnapPoints} - enablePanDownToClose - keyboardBehavior="interactive" - android_keyboardInputMode="adjustResize" - keyboardBlurBehavior="restore" - topInset={insets.top} - {...sheetOptions} - ref={sheet} - index={-1} - backgroundStyle={{backgroundColor: 'transparent'}} - backdropComponent={props => ( - <BottomSheetBackdrop - opacity={0.4} - appearsOnIndex={0} - disappearsOnIndex={-1} - {...props} - /> - )} - handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} - handleStyle={{display: 'none'}} - onChange={onChange}> - <Context.Provider value={context}> - <View - style={[ - a.absolute, - a.inset_0, - t.atoms.bg, - { - borderTopLeftRadius: 40, - borderTopRightRadius: 40, - height: Dimensions.get('window').height * 2, - }, - ]} - /> - {children} - </Context.Provider> - </BottomSheet> - </Portal> + isOpen && ( + <Portal> + <View + // iOS + accessibilityViewIsModal + // Android + importantForAccessibility="yes" + style={[a.absolute, a.inset_0]} + testID={testID}> + <BottomSheet + enableDynamicSizing={!hasSnapPoints} + enablePanDownToClose + keyboardBehavior="interactive" + android_keyboardInputMode="adjustResize" + keyboardBlurBehavior="restore" + topInset={insets.top} + {...sheetOptions} + snapPoints={sheetOptions.snapPoints || ['100%']} + ref={sheet} + index={openIndex} + backgroundStyle={{backgroundColor: 'transparent'}} + backdropComponent={Backdrop} + handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} + handleStyle={{display: 'none'}} + onClose={onCloseInner}> + <Context.Provider value={context}> + <View + style={[ + a.absolute, + a.inset_0, + t.atoms.bg, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + height: Dimensions.get('window').height * 2, + }, + ]} + /> + {children} + </Context.Provider> + </BottomSheet> + </View> + </Portal> + ) ) } -// TODO a11y props here, or is that handled by the sheet? -export function Inner(props: DialogInnerProps) { +export function Inner({children, style}: DialogInnerProps) { const insets = useSafeAreaInsets() return ( <BottomSheetView style={[ - a.p_lg, + a.p_xl, { paddingTop: 40, borderTopLeftRadius: 40, borderTopRightRadius: 40, paddingBottom: insets.bottom + a.pb_5xl.paddingBottom, }, + flatten(style), ]}> - {props.children} + {children} </BottomSheetView> ) } -export function ScrollableInner(props: DialogInnerProps) { +export const ScrollableInner = React.forwardRef< + BottomSheetScrollViewMethods, + DialogInnerProps +>(function ScrollableInner({children, style}, ref) { const insets = useSafeAreaInsets() return ( <BottomSheetScrollView keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" style={[ a.flex_1, // main diff is this a.p_xl, + a.h_full, { paddingTop: 40, borderTopLeftRadius: 40, borderTopRightRadius: 40, }, - ]}> - {props.children} + flatten(style), + ]} + contentContainerStyle={isNative ? a.pb_4xl : undefined} + ref={ref}> + {children} <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> </BottomSheetScrollView> ) -} +}) export function Handle() { const t = useTheme() + return ( <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}> <View diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 305c00e97..038f6295a 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -5,11 +5,14 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useTheme, atoms as a, useBreakpoints, web} from '#/alf' +import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf' import {Portal} from '#/components/Portal' import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types' import {Context} from '#/components/Dialog/context' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {useDialogStateControlContext} from '#/state/dialogs' export {useDialogControl, useDialogContext} from '#/components/Dialog/context' export * from '#/components/Dialog/types' @@ -18,27 +21,30 @@ export {Input} from '#/components/forms/TextField' const stopPropagation = (e: any) => e.stopPropagation() export function Outer({ + children, control, onClose, - children, }: React.PropsWithChildren<DialogOuterProps>) { const {_} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() const [isOpen, setIsOpen] = React.useState(false) const [isVisible, setIsVisible] = React.useState(true) + const {setDialogIsOpen} = useDialogStateControlContext() const open = React.useCallback(() => { setIsOpen(true) - }, [setIsOpen]) + setDialogIsOpen(control.id, true) + }, [setIsOpen, setDialogIsOpen, control.id]) const close = React.useCallback(async () => { setIsVisible(false) await new Promise(resolve => setTimeout(resolve, 150)) setIsOpen(false) setIsVisible(true) + setDialogIsOpen(control.id, false) onClose?.() - }, [onClose, setIsOpen]) + }, [onClose, setIsOpen, setDialogIsOpen, control.id]) useImperativeHandle( control.ref, @@ -93,7 +99,7 @@ export function Outer({ style={[ web(a.fixed), a.inset_0, - {opacity: 0.5, backgroundColor: t.palette.black}, + {opacity: 0.8, backgroundColor: t.palette.black}, ]} /> )} @@ -147,7 +153,7 @@ export function Inner({ a.rounded_md, a.w_full, a.border, - gtMobile ? a.p_xl : a.p_lg, + gtMobile ? a.p_2xl : a.p_xl, t.atoms.bg, { maxWidth: 600, @@ -156,7 +162,7 @@ export function Inner({ shadowOpacity: t.name === 'light' ? 0.1 : 0.4, shadowRadius: 30, }, - ...(Array.isArray(style) ? style : [style || {}]), + flatten(style), ]}> {children} </Animated.View> @@ -170,25 +176,28 @@ export function Handle() { return null } -/** - * TODO(eric) unused rn - */ -// export function Close() { -// const {_} = useLingui() -// const t = useTheme() -// const {close} = useDialogContext() -// return ( -// <View -// style={[ -// a.absolute, -// a.z_10, -// { -// top: a.pt_lg.paddingTop, -// right: a.pr_lg.paddingRight, -// }, -// ]}> -// <Button onPress={close} label={_(msg`Close active dialog`)}> -// </Button> -// </View> -// ) -// } +export function Close() { + const {_} = useLingui() + const {close} = React.useContext(Context) + return ( + <View + style={[ + a.absolute, + a.z_10, + { + top: a.pt_md.paddingTop, + right: a.pr_md.paddingRight, + }, + ]}> + <Button + size="small" + variant="ghost" + color="secondary" + shape="round" + onPress={() => close()} + label={_(msg`Close active dialog`)}> + <ButtonIcon icon={X} size="md" /> + </Button> + </View> + ) +} diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index d36784183..1ddab02ee 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -1,43 +1,75 @@ import React from 'react' -import type {ViewStyle, AccessibilityProps} from 'react-native' -import {BottomSheetProps} from '@gorhom/bottom-sheet' +import type { + AccessibilityProps, + GestureResponderEvent, + ScrollViewProps, +} from 'react-native' +import {BottomSheetProps} from '@discord/bottom-sheet/src' + +import {ViewStyleProp} from '#/alf' type A11yProps = Required<AccessibilityProps> +/** + * Mutated by useImperativeHandle to provide a public API for controlling the + * dialog. The methods here will actually become the handlers defined within + * the `Dialog.Outer` component. + * + * `Partial<GestureResponderEvent>` here allows us to add this directly to the + * `onPress` prop of a button, for example. If this type was not added, we + * would need to create a function to wrap `.open()` with. + */ +export type DialogControlRefProps = { + open: ( + options?: DialogControlOpenOptions & Partial<GestureResponderEvent>, + ) => void + close: (callback?: () => void) => void +} + +/** + * The return type of the useDialogControl hook. + */ +export type DialogControlProps = DialogControlRefProps & { + id: string + ref: React.RefObject<DialogControlRefProps> + isOpen?: boolean +} + export type DialogContextProps = { - close: () => void + close: DialogControlProps['close'] } -export type DialogControlProps = { - open: (index?: number) => void - close: () => void +export type DialogControlOpenOptions = { + /** + * NATIVE ONLY + * + * Optional index of the snap point to open the bottom sheet to. Defaults to + * 0, which is the first snap point (i.e. "open"). + */ + index?: number } export type DialogOuterProps = { - control: { - ref: React.RefObject<DialogControlProps> - open: (index?: number) => void - close: () => void - } + control: DialogControlProps onClose?: () => void nativeOptions?: { sheet?: Omit<BottomSheetProps, 'children'> } webOptions?: {} + testID?: string } -type DialogInnerPropsBase<T> = React.PropsWithChildren<{ - style?: ViewStyle -}> & - T +type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T export type DialogInnerProps = | DialogInnerPropsBase<{ label?: undefined accessibilityLabelledBy: A11yProps['aria-labelledby'] accessibilityDescribedBy: string + keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] }> | DialogInnerPropsBase<{ label: string accessibilityLabelledBy?: undefined accessibilityDescribedBy?: undefined + keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] }> diff --git a/src/components/Error.tsx b/src/components/Error.tsx new file mode 100644 index 000000000..7df166c3f --- /dev/null +++ b/src/components/Error.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import {View} from 'react-native' +import {useNavigation} from '@react-navigation/core' +import {StackActions} from '@react-navigation/native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {NavigationProp} from 'lib/routes/types' +import {CenteredView} from 'view/com/util/Views' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {router} from '#/routes' + +export function Error({ + title, + message, + onRetry, +}: { + title?: string + message?: string + onRetry?: () => unknown +}) { + const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + + const canGoBack = navigation.canGoBack() + const onGoBack = React.useCallback(() => { + if (canGoBack) { + navigation.goBack() + } else { + navigation.navigate('HomeTab') + + // Checking the state for routes ensures that web doesn't encounter errors while going back + if (navigation.getState()?.routes) { + navigation.dispatch(StackActions.push(...router.matchPath('/'))) + } else { + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) + } + } + }, [navigation, canGoBack]) + + return ( + <CenteredView + style={[ + a.flex_1, + a.align_center, + !gtMobile ? a.justify_between : a.gap_5xl, + t.atoms.border_contrast_low, + {paddingTop: 175, paddingBottom: 110}, + ]} + sideBorders> + <View style={[a.w_full, a.align_center, a.gap_lg]}> + <Text style={[a.font_bold, a.text_3xl]}>{title}</Text> + <Text + style={[ + a.text_md, + a.text_center, + t.atoms.text_contrast_high, + {lineHeight: 1.4}, + gtMobile && {width: 450}, + ]}> + {message} + </Text> + </View> + <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}> + {onRetry && ( + <Button + variant="solid" + color="primary" + label={_(msg`Press to retry`)} + onPress={onRetry} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + </Button> + )} + <Button + variant="solid" + color={onRetry ? 'secondary' : 'primary'} + label={_(msg`Return to previous page`)} + onPress={onGoBack} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + <ButtonText> + <Trans>Go Back</Trans> + </ButtonText> + </Button> + </View> + </CenteredView> + ) +} diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx new file mode 100644 index 000000000..dc14aa72b --- /dev/null +++ b/src/components/GradientFill.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import LinearGradient from 'react-native-linear-gradient' + +import {atoms as a, tokens} from '#/alf' + +export function GradientFill({ + gradient, +}: { + gradient: + | typeof tokens.gradients.sky + | typeof tokens.gradients.midnight + | typeof tokens.gradients.sunrise + | typeof tokens.gradients.sunset + | typeof tokens.gradients.bonfire + | typeof tokens.gradients.summer + | typeof tokens.gradients.nordic +}) { + return ( + <LinearGradient + colors={gradient.values.map(c => c[1])} + locations={gradient.values.map(c => c[0])} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[a.absolute, a.inset_0]} + /> + ) +} diff --git a/src/components/IconCircle.tsx b/src/components/IconCircle.tsx new file mode 100644 index 000000000..aa779e37f --- /dev/null +++ b/src/components/IconCircle.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import {View} from 'react-native' + +import { + useTheme, + atoms as a, + ViewStyleProp, + TextStyleProp, + flatten, +} from '#/alf' +import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' +import {Props} from '#/components/icons/common' + +export function IconCircle({ + icon: Icon, + size = 'xl', + style, + iconStyle, +}: ViewStyleProp & { + icon: typeof Growth + size?: Props['size'] + iconStyle?: TextStyleProp['style'] +}) { + const t = useTheme() + + return ( + <View + style={[ + a.justify_center, + a.align_center, + a.rounded_full, + { + width: size === 'lg' ? 52 : 64, + height: size === 'lg' ? 52 : 64, + backgroundColor: + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, + }, + flatten(style), + ]}> + <Icon + size={size} + style={[ + { + color: t.palette.primary_500, + }, + flatten(iconStyle), + ]} + /> + </View> + ) +} diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx new file mode 100644 index 000000000..f924f0f59 --- /dev/null +++ b/src/components/LabelingServiceCard/index.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyLabelerDefs} from '@atproto/api' + +import {getLabelingServiceTitle} from '#/lib/moderation' +import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Text} from '#/components/Typography' +import {useLabelerInfoQuery} from '#/state/queries/labeler' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {RichText} from '#/components/RichText' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {sanitizeHandle} from '#/lib/strings/handles' +import {pluralize} from '#/lib/strings/helpers' + +type LabelingServiceProps = { + labeler: AppBskyLabelerDefs.LabelerViewDetailed +} + +export function Outer({ + children, + style, +}: React.PropsWithChildren<ViewStyleProp>) { + return ( + <View + style={[ + a.flex_row, + a.gap_md, + a.w_full, + a.p_lg, + a.pr_md, + a.overflow_hidden, + style, + ]}> + {children} + </View> + ) +} + +export function Avatar({avatar}: {avatar?: string}) { + return <UserAvatar type="labeler" size={40} avatar={avatar} /> +} + +export function Title({value}: {value: string}) { + return <Text style={[a.text_md, a.font_bold]}>{value}</Text> +} + +export function Description({value, handle}: {value?: string; handle: string}) { + return value ? ( + <Text numberOfLines={2}> + <RichText value={value} style={[]} /> + </Text> + ) : ( + <Text> + <Trans>By {sanitizeHandle(handle, '@')}</Trans> + </Text> + ) +} + +export function LikeCount({count}: {count: number}) { + const t = useTheme() + return ( + <Text + style={[ + a.mt_sm, + a.text_sm, + t.atoms.text_contrast_medium, + {fontWeight: '500'}, + ]}> + <Trans> + Liked by {count} {pluralize(count, 'user')} + </Trans> + </Text> + ) +} + +export function Content({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + + return ( + <View + style={[ + a.flex_1, + a.flex_row, + a.gap_md, + a.align_center, + a.justify_between, + ]}> + <View style={[a.gap_xs, a.flex_1]}>{children}</View> + + <ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} /> + </View> + ) +} + +/** + * The canonical view for a labeling service. Use this or compose your own. + */ +export function Default({ + labeler, + style, +}: LabelingServiceProps & ViewStyleProp) { + return ( + <Outer style={style}> + <Avatar avatar={labeler.creator.avatar} /> + <Content> + <Title + value={getLabelingServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + })} + /> + <Description + value={labeler.creator.description} + handle={labeler.creator.handle} + /> + {labeler.likeCount ? <LikeCount count={labeler.likeCount} /> : null} + </Content> + </Outer> + ) +} + +export function Link({ + children, + labeler, +}: LabelingServiceProps & Pick<LinkProps, 'children'>) { + const {_} = useLingui() + + return ( + <InternalLink + to={{ + screen: 'Profile', + params: { + name: labeler.creator.handle, + }, + }} + label={_( + msg`View the labeling service provided by @${labeler.creator.handle}`, + )}> + {children} + </InternalLink> + ) +} + +// TODO not finished yet +export function DefaultSkeleton() { + return ( + <View> + <Text>Loading</Text> + </View> + ) +} + +export function Loader({ + did, + loading: LoadingComponent = DefaultSkeleton, + error: ErrorComponent, + component: Component, +}: { + did: string + loading?: React.ComponentType<{}> + error?: React.ComponentType<{error: string}> + component: React.ComponentType<{ + labeler: AppBskyLabelerDefs.LabelerViewDetailed + }> +}) { + const {isLoading, data, error} = useLabelerInfoQuery({did}) + + return isLoading ? ( + LoadingComponent ? ( + <LoadingComponent /> + ) : null + ) : error || !data ? ( + ErrorComponent ? ( + <ErrorComponent error={error?.message || 'Unknown error'} /> + ) : null + ) : ( + <Component labeler={data} /> + ) +} diff --git a/src/components/LikedByList.tsx b/src/components/LikedByList.tsx new file mode 100644 index 000000000..bd1213639 --- /dev/null +++ b/src/components/LikedByList.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {logger} from '#/logger' +import {List} from '#/view/com/util/List' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {ListFooter} from '#/components/Lists' + +import {atoms as a, useTheme} from '#/alf' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function LikedByList({uri}: {uri: string}) { + const t = useTheme() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data: resolvedUri, + error: resolveError, + isFetching: isFetchingResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching, + isFetched, + isRefetching, + hasNextPage, + fetchNextPage, + isError, + error: likedByError, + refetch, + } = useLikedByQuery(resolvedUri?.uri) + const likes = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.likes) + } + return [] + }, [data]) + const initialNumToRender = useInitialNumToRender() + const error = resolveError || likedByError + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh likes', {message: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more likes', {message: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => { + return ( + <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> + ) + }, []) + + if (isFetchingResolvedUri || !isFetched) { + return ( + <View style={[a.w_full, a.align_center, a.p_lg]}> + <Loader size="xl" /> + </View> + ) + } + + return likes.length ? ( + <List + data={likes} + keyExtractor={item => item.actor.did} + refreshing={isPTRing} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={3} + renderItem={renderItem} + initialNumToRender={initialNumToRender} + ListFooterComponent={() => ( + <ListFooter + isFetching={isFetching && !isRefetching} + isError={isError} + error={error ? error.toString() : undefined} + onRetry={fetchNextPage} + /> + )} + /> + ) : ( + <View style={[a.p_lg]}> + <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + Nobody has liked this yet. Maybe you should be the first! + </Trans> + </Text> + </View> + </View> + ) +} diff --git a/src/components/LikesDialog.tsx b/src/components/LikesDialog.tsx new file mode 100644 index 000000000..94a3f27e2 --- /dev/null +++ b/src/components/LikesDialog.tsx @@ -0,0 +1,131 @@ +import React, {useMemo, useCallback} from 'react' +import {ActivityIndicator, FlatList, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' + +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {Loader} from '#/components/Loader' + +interface LikesDialogProps { + control: Dialog.DialogOuterProps['control'] + uri: string +} + +export function LikesDialog(props: LikesDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + + <LikesDialogInner {...props} /> + </Dialog.Outer> + ) +} + +export function LikesDialogInner({control, uri}: LikesDialogProps) { + const {_} = useLingui() + const t = useTheme() + + const { + data: resolvedUri, + error: resolveError, + isFetched: hasFetchedResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching: isFetchingLikedBy, + isFetched: hasFetchedLikedBy, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error: likedByError, + } = useLikedByQuery(resolvedUri?.uri) + + const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy + const likes = useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.likes) + } + return [] + }, [data]) + + const onEndReached = useCallback(async () => { + if (isFetchingLikedBy || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more likes', {message: err}) + } + }, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage]) + + const renderItem = useCallback( + ({item}: {item: GetLikes.Like}) => { + return ( + <ProfileCardWithFollowBtn + key={item.actor.did} + profile={item.actor} + onPress={() => control.close()} + /> + ) + }, + [control], + ) + + return ( + <Dialog.Inner label={_(msg`Users that have liked this content or profile`)}> + <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}> + <Trans>Liked by</Trans> + </Text> + + {isLoading ? ( + <View style={{minHeight: 300}}> + <Loader size="xl" /> + </View> + ) : resolveError || likedByError || !data ? ( + <ErrorMessage message={cleanError(resolveError || likedByError)} /> + ) : likes.length === 0 ? ( + <View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}> + <Text style={[a.text_center]}> + <Trans> + Nobody has liked this yet. Maybe you should be the first! + </Trans> + </Text> + </View> + ) : ( + <FlatList + data={likes} + keyExtractor={item => item.actor.did} + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + ListFooterComponent={ + <ListFooterComponent isFetching={isFetchingNextPage} /> + } + /> + )} + + <Dialog.Close /> + </Dialog.Inner> + ) +} + +function ListFooterComponent({isFetching}: {isFetching: boolean}) { + if (isFetching) { + return ( + <View style={a.pt_lg}> + <ActivityIndicator /> + </View> + ) + } + return null +} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 763f07ca9..7d0e83332 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -1,21 +1,13 @@ import React from 'react' -import { - GestureResponderEvent, - Linking, - TouchableWithoutFeedback, -} from 'react-native' -import { - useLinkProps, - useNavigation, - StackActions, -} from '@react-navigation/native' +import {GestureResponderEvent} from 'react-native' +import {useLinkProps, StackActions} from '@react-navigation/native' import {sanitizeUrl} from '@braintree/sanitize-url' import {useInteractionState} from '#/components/hooks/useInteractionState' import {isWeb} from '#/platform/detection' -import {useTheme, web, flatten, TextStyleProp} from '#/alf' +import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf' import {Button, ButtonProps} from '#/components/Button' -import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {AllNavigatorParams} from '#/lib/routes/types' import { convertBskyAppUrlIfNeeded, isExternalUrl, @@ -23,7 +15,9 @@ import { } from '#/lib/strings/url-helpers' import {useModalControls} from '#/state/modals' import {router} from '#/routes' -import {Text} from '#/components/Typography' +import {Text, TextProps} from '#/components/Typography' +import {useOpenLink} from 'state/preferences/in-app-browser' +import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' /** * Only available within a `Link`, since that inherits from `Button`. @@ -35,6 +29,13 @@ type BaseLinkProps = Pick< Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to' > & { + testID?: string + + /** + * Label for a11y. Defaults to the href. + */ + label?: string + /** * The React Navigation `StackAction` to perform when the link is pressed. */ @@ -45,29 +46,48 @@ type BaseLinkProps = Pick< * * Note: atm this only works for `InlineLink`s with a string child. */ - warnOnMismatchingTextChild?: boolean + disableMismatchWarning?: boolean + + /** + * Callback for when the link is pressed. Prevent default and return `false` + * to exit early and prevent navigation. + * + * DO NOT use this for navigation, that's what the `to` prop is for. + */ + onPress?: (e: GestureResponderEvent) => void | false + + /** + * Web-only attribute. Sets `download` attr on web. + */ + download?: string } export function useLink({ to, displayText, action = 'push', - warnOnMismatchingTextChild, + disableMismatchWarning, + onPress: outerOnPress, }: BaseLinkProps & { displayText: string }) { - const navigation = useNavigation<NavigationProp>() + const navigation = useNavigationDeduped() const {href} = useLinkProps<AllNavigatorParams>({ to: typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to, }) const isExternal = isExternalUrl(href) const {openModal, closeModal} = useModalControls() + const openLink = useOpenLink() const onPress = React.useCallback( (e: GestureResponderEvent) => { + const exitEarlyIfFalse = outerOnPress?.(e) + + if (exitEarlyIfFalse === false) return + const requiresWarning = Boolean( - warnOnMismatchingTextChild && + !disableMismatchWarning && displayText && isExternal && linkRequiresWarning(href, displayText), @@ -85,7 +105,7 @@ export function useLink({ e.preventDefault() if (isExternal) { - Linking.openURL(href) + openLink(href) } else { /** * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch @@ -103,7 +123,7 @@ export function useLink({ href.startsWith('http') || href.startsWith('mailto') ) { - Linking.openURL(href) + openLink(href) } else { closeModal() // close any active modals @@ -124,14 +144,16 @@ export function useLink({ } }, [ - href, - isExternal, - warnOnMismatchingTextChild, - navigation, - action, + outerOnPress, + disableMismatchWarning, displayText, - closeModal, + isExternal, + href, openModal, + openLink, + closeModal, + action, + navigation, ], ) @@ -142,17 +164,8 @@ export function useLink({ } } -export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> & - Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & { - /** - * Label for a11y. Defaults to the href. - */ - label?: string - /** - * Web-only attribute. Sets `download` attr on web. - */ - download?: string - } +export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> & + Omit<ButtonProps, 'onPress' | 'disabled' | 'label'> /** * A interactive element that renders as a `<a>` tag on the web. On mobile it @@ -166,6 +179,7 @@ export function Link({ children, to, action = 'push', + onPress: outerOnPress, download, ...rest }: LinkProps) { @@ -173,24 +187,26 @@ export function Link({ to, displayText: typeof children === 'string' ? children : '', action, + onPress: outerOnPress, }) return ( <Button label={href} {...rest} + style={[a.justify_start, flatten(rest.style)]} role="link" accessibilityRole="link" href={href} - onPress={onPress} + onPress={download ? undefined : onPress} {...web({ hrefAttrs: { - target: isExternal ? 'blank' : undefined, + target: download ? undefined : isExternal ? 'blank' : undefined, rel: isExternal ? 'noopener noreferrer' : undefined, download, }, dataSet: { - // default to no underline, apply this ourselves + // no underline, only `InlineLink` has underlines noUnderline: '1', }, })}> @@ -200,21 +216,19 @@ export function Link({ } export type InlineLinkProps = React.PropsWithChildren< - BaseLinkProps & - TextStyleProp & { - /** - * Label for a11y. Defaults to the href. - */ - label?: string - } + BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'> > export function InlineLink({ children, to, action = 'push', - warnOnMismatchingTextChild, + disableMismatchWarning, style, + onPress: outerOnPress, + download, + selectable, + label, ...rest }: InlineLinkProps) { const t = useTheme() @@ -223,52 +237,59 @@ export function InlineLink({ to, displayText: stringChildren ? children : '', action, - warnOnMismatchingTextChild, + disableMismatchWarning, + onPress: outerOnPress, }) + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const { state: pressed, onIn: onPressIn, onOut: onPressOut, } = useInteractionState() + const flattenedStyle = flatten(style) || {} return ( - <TouchableWithoutFeedback - accessibilityRole="button" - onPress={onPress} + <Text + selectable={selectable} + accessibilityHint="" + accessibilityLabel={label || href} + {...rest} + style={[ + {color: t.palette.primary_500}, + (hovered || focused || pressed) && { + ...web({outline: 0}), + textDecorationLine: 'underline', + textDecorationColor: flattenedStyle.color ?? t.palette.primary_500, + }, + flattenedStyle, + ]} + role="link" + onPress={download ? undefined : onPress} onPressIn={onPressIn} onPressOut={onPressOut} onFocus={onFocus} - onBlur={onBlur}> - <Text - label={href} - {...rest} - style={[ - {color: t.palette.primary_500}, - (focused || pressed) && { - outline: 0, - textDecorationLine: 'underline', - textDecorationColor: t.palette.primary_500, - }, - flatten(style), - ]} - role="link" - accessibilityRole="link" - href={href} - {...web({ - hrefAttrs: { - target: isExternal ? 'blank' : undefined, - rel: isExternal ? 'noopener noreferrer' : undefined, - }, - dataSet: stringChildren - ? {} - : { - // default to no underline, apply this ourselves - noUnderline: '1', - }, - })}> - {children} - </Text> - </TouchableWithoutFeedback> + onBlur={onBlur} + onMouseEnter={onHoverIn} + onMouseLeave={onHoverOut} + accessibilityRole="link" + href={href} + {...web({ + hrefAttrs: { + target: download ? undefined : isExternal ? 'blank' : undefined, + rel: isExternal ? 'noopener noreferrer' : undefined, + download, + }, + dataSet: { + // default to no underline, apply this ourselves + noUnderline: '1', + }, + })}> + {children} + </Text> ) } diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx new file mode 100644 index 000000000..d3e072028 --- /dev/null +++ b/src/components/Lists.tsx @@ -0,0 +1,200 @@ +import React from 'react' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' + +import {CenteredView} from 'view/com/util/Views' +import {Loader} from '#/components/Loader' +import {cleanError} from 'lib/strings/errors' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' +import {Error} from '#/components/Error' + +export function ListFooter({ + isFetching, + isError, + error, + onRetry, + height, +}: { + isFetching?: boolean + isError?: boolean + error?: string + onRetry?: () => Promise<unknown> + height?: number +}) { + const t = useTheme() + + return ( + <View + style={[ + a.w_full, + a.align_center, + a.border_t, + a.pb_lg, + t.atoms.border_contrast_low, + {height: height ?? 180, paddingTop: 30}, + ]}> + {isFetching ? ( + <Loader size="xl" /> + ) : ( + <ListFooterMaybeError + isError={isError} + error={error} + onRetry={onRetry} + /> + )} + </View> + ) +} + +function ListFooterMaybeError({ + isError, + error, + onRetry, +}: { + isError?: boolean + error?: string + onRetry?: () => Promise<unknown> +}) { + const t = useTheme() + const {_} = useLingui() + + if (!isError) return null + + return ( + <View style={[a.w_full, a.px_lg]}> + <View + style={[ + a.flex_row, + a.gap_md, + a.p_md, + a.rounded_sm, + a.align_center, + t.atoms.bg_contrast_25, + ]}> + <Text + style={[a.flex_1, a.text_sm, t.atoms.text_contrast_medium]} + numberOfLines={2}> + {error ? ( + cleanError(error) + ) : ( + <Trans>Oops, something went wrong!</Trans> + )} + </Text> + <Button + variant="gradient" + label={_(msg`Press to retry`)} + style={[ + a.align_center, + a.justify_center, + a.rounded_sm, + a.overflow_hidden, + a.px_md, + a.py_sm, + ]} + onPress={onRetry}> + <Trans>Retry</Trans> + </Button> + </View> + </View> + ) +} + +export function ListHeaderDesktop({ + title, + subtitle, +}: { + title: string + subtitle?: string +}) { + const {gtTablet} = useBreakpoints() + const t = useTheme() + + if (!gtTablet) return null + + return ( + <View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}> + <Text style={[a.text_3xl, a.font_bold]}>{title}</Text> + {subtitle ? ( + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + {subtitle} + </Text> + ) : undefined} + </View> + ) +} + +export function ListMaybePlaceholder({ + isLoading, + isEmpty, + isError, + emptyTitle, + emptyMessage, + errorTitle, + errorMessage, + emptyType = 'page', + onRetry, +}: { + isLoading: boolean + isEmpty?: boolean + isError?: boolean + emptyTitle?: string + emptyMessage?: string + errorTitle?: string + errorMessage?: string + emptyType?: 'page' | 'results' + onRetry?: () => Promise<unknown> +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile, gtTablet} = useBreakpoints() + + if (!isLoading && isError) { + return ( + <Error + title={errorTitle ?? _(msg`Oops!`)} + message={errorMessage ?? _(`Something went wrong!`)} + onRetry={onRetry} + /> + ) + } + + if (isLoading) { + return ( + <CenteredView + style={[ + a.flex_1, + a.align_center, + !gtMobile ? a.justify_between : a.gap_5xl, + t.atoms.border_contrast_low, + {paddingTop: 175, paddingBottom: 110}, + ]} + sideBorders={gtMobile} + topBorder={!gtTablet}> + <View style={[a.w_full, a.align_center, {top: 100}]}> + <Loader size="xl" /> + </View> + </CenteredView> + ) + } + + if (isEmpty) { + return ( + <Error + title={ + emptyTitle ?? + (emptyType === 'results' + ? _(msg`No results found`) + : _(msg`Page not found`)) + } + message={ + emptyMessage ?? + _(msg`We're sorry! We can't find the page you were looking for.`) + } + onRetry={onRetry} + /> + ) + } +} diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx index bbe4e2f75..b9f399f95 100644 --- a/src/components/Loader.tsx +++ b/src/components/Loader.tsx @@ -7,11 +7,12 @@ import Animated, { withTiming, } from 'react-native-reanimated' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme, flatten} from '#/alf' import {Props, useCommonSVGProps} from '#/components/icons/common' import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader' export function Loader(props: Props) { + const t = useTheme() const common = useCommonSVGProps(props) const rotation = useSharedValue(0) @@ -35,7 +36,15 @@ export function Loader(props: Props) { {width: common.size, height: common.size}, animatedStyles, ]}> - <Icon {...props} style={[a.absolute, a.inset_0, props.style]} /> + <Icon + {...props} + style={[ + a.absolute, + a.inset_0, + t.atoms.text_contrast_high, + flatten(props.style), + ]} + /> </Animated.View> ) } diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx new file mode 100644 index 000000000..9fc91f681 --- /dev/null +++ b/src/components/Menu/context.tsx @@ -0,0 +1,8 @@ +import React from 'react' + +import type {ContextType} from '#/components/Menu/types' + +export const Context = React.createContext<ContextType>({ + // @ts-ignore + control: null, +}) diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx new file mode 100644 index 000000000..051e95b95 --- /dev/null +++ b/src/components/Menu/index.tsx @@ -0,0 +1,221 @@ +import React from 'react' +import {View, Pressable, ViewStyle, StyleProp} from 'react-native' +import flattenReactChildren from 'react-keyed-flatten-children' + +import {atoms as a, useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Text} from '#/components/Typography' + +import {Context} from '#/components/Menu/context' +import { + ContextType, + TriggerProps, + ItemProps, + GroupProps, + ItemTextProps, + ItemIconProps, +} from '#/components/Menu/types' +import {Button, ButtonText} from '#/components/Button' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {isNative} from 'platform/detection' + +export {useDialogControl as useMenuControl} from '#/components/Dialog' + +export function useMemoControlContext() { + return React.useContext(Context) +} + +export function Root({ + children, + control, +}: React.PropsWithChildren<{ + control?: Dialog.DialogOuterProps['control'] +}>) { + const defaultControl = Dialog.useDialogControl() + const context = React.useMemo<ContextType>( + () => ({ + control: control || defaultControl, + }), + [control, defaultControl], + ) + + return <Context.Provider value={context}>{children}</Context.Provider> +} + +export function Trigger({children, label}: TriggerProps) { + const {control} = React.useContext(Context) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + return children({ + isNative: true, + control, + state: { + hovered: false, + focused, + pressed, + }, + props: { + onPress: control.open, + onFocus, + onBlur, + onPressIn, + onPressOut, + accessibilityLabel: label, + }, + }) +} + +export function Outer({ + children, + showCancel, +}: React.PropsWithChildren<{ + showCancel?: boolean + style?: StyleProp<ViewStyle> +}>) { + const context = React.useContext(Context) + + return ( + <Dialog.Outer control={context.control}> + <Dialog.Handle /> + + {/* Re-wrap with context since Dialogs are portal-ed to root */} + <Context.Provider value={context}> + <Dialog.ScrollableInner label="Menu TODO"> + <View style={[a.gap_lg]}> + {children} + {isNative && showCancel && <Cancel />} + </View> + <View style={{height: a.gap_lg.gap}} /> + </Dialog.ScrollableInner> + </Context.Provider> + </Dialog.Outer> + ) +} + +export function Item({children, label, style, onPress, ...rest}: ItemProps) { + const t = useTheme() + const {control} = React.useContext(Context) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + return ( + <Pressable + {...rest} + accessibilityHint="" + accessibilityLabel={label} + onPress={e => { + onPress(e) + + if (!e.defaultPrevented) { + control?.close() + } + }} + onFocus={onFocus} + onBlur={onBlur} + onPressIn={onPressIn} + onPressOut={onPressOut} + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + a.px_md, + a.rounded_md, + a.border, + t.atoms.bg_contrast_25, + t.atoms.border_contrast_low, + {minHeight: 44, paddingVertical: 10}, + style, + (focused || pressed) && [t.atoms.bg_contrast_50], + ]}> + {children} + </Pressable> + ) +} + +export function ItemText({children, style}: ItemTextProps) { + const t = useTheme() + return ( + <Text + numberOfLines={1} + ellipsizeMode="middle" + style={[ + a.flex_1, + a.text_md, + a.font_bold, + t.atoms.text_contrast_medium, + {paddingTop: 3}, + style, + ]}> + {children} + </Text> + ) +} + +export function ItemIcon({icon: Comp}: ItemIconProps) { + const t = useTheme() + return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} /> +} + +export function Group({children, style}: GroupProps) { + const t = useTheme() + return ( + <View + style={[ + a.rounded_md, + a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, + style, + ]}> + {flattenReactChildren(children).map((child, i) => { + return React.isValidElement(child) && child.type === Item ? ( + <React.Fragment key={i}> + {i > 0 ? ( + <View style={[a.border_b, t.atoms.border_contrast_low]} /> + ) : null} + {React.cloneElement(child, { + // @ts-ignore + style: { + borderRadius: 0, + borderWidth: 0, + }, + })} + </React.Fragment> + ) : null + })} + </View> + ) +} + +function Cancel() { + const {_} = useLingui() + const {control} = React.useContext(Context) + + return ( + <Button + label={_(msg`Close this dialog`)} + size="small" + variant="ghost" + color="secondary" + onPress={() => control.close()}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + ) +} + +export function Divider() { + return null +} diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx new file mode 100644 index 000000000..60b234203 --- /dev/null +++ b/src/components/Menu/index.web.tsx @@ -0,0 +1,293 @@ +/* eslint-disable react/prop-types */ + +import React from 'react' +import {View, Pressable, ViewStyle, StyleProp} from 'react-native' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as Dialog from '#/components/Dialog' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {atoms as a, useTheme, flatten, web} from '#/alf' +import {Text} from '#/components/Typography' + +import { + ContextType, + TriggerProps, + ItemProps, + GroupProps, + ItemTextProps, + ItemIconProps, + RadixPassThroughTriggerProps, +} from '#/components/Menu/types' +import {Context} from '#/components/Menu/context' +import {Portal} from '#/components/Portal' + +export function useMenuControl(): Dialog.DialogControlProps { + const id = React.useId() + const [isOpen, setIsOpen] = React.useState(false) + + return React.useMemo( + () => ({ + id, + ref: {current: null}, + isOpen, + open() { + setIsOpen(true) + }, + close() { + setIsOpen(false) + }, + }), + [id, isOpen, setIsOpen], + ) +} + +export function useMemoControlContext() { + return React.useContext(Context) +} + +export function Root({ + children, + control, +}: React.PropsWithChildren<{ + control?: Dialog.DialogOuterProps['control'] +}>) { + const {_} = useLingui() + const defaultControl = useMenuControl() + const context = React.useMemo<ContextType>( + () => ({ + control: control || defaultControl, + }), + [control, defaultControl], + ) + const onOpenChange = React.useCallback( + (open: boolean) => { + if (context.control.isOpen && !open) { + context.control.close() + } else if (!context.control.isOpen && open) { + context.control.open() + } + }, + [context.control], + ) + + return ( + <Context.Provider value={context}> + {context.control.isOpen && ( + <Portal> + <Pressable + style={[a.fixed, a.inset_0, a.z_50]} + onPress={() => context.control.close()} + accessibilityHint="" + accessibilityLabel={_( + msg`Context menu backdrop, click to close the menu.`, + )} + /> + </Portal> + )} + <DropdownMenu.Root + open={context.control.isOpen} + onOpenChange={onOpenChange}> + {children} + </DropdownMenu.Root> + </Context.Provider> + ) +} + +const RadixTriggerPassThrough = React.forwardRef( + ( + props: { + children: ( + props: RadixPassThroughTriggerProps & { + ref: React.Ref<any> + }, + ) => React.ReactNode + }, + ref, + ) => { + // @ts-expect-error Radix provides no types of this stuff + return props.children({...props, ref}) + }, +) +RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough' + +export function Trigger({children, label}: TriggerProps) { + const {control} = React.useContext(Context) + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + return ( + <DropdownMenu.Trigger asChild> + <RadixTriggerPassThrough> + {props => + children({ + isNative: false, + control, + state: { + hovered, + focused, + pressed: false, + }, + props: { + ...props, + // disable on web, use `onPress` + onPointerDown: () => false, + onPress: () => + control.isOpen ? control.close() : control.open(), + onFocus: onFocus, + onBlur: onBlur, + onMouseEnter, + onMouseLeave, + accessibilityLabel: label, + }, + }) + } + </RadixTriggerPassThrough> + </DropdownMenu.Trigger> + ) +} + +export function Outer({ + children, + style, +}: React.PropsWithChildren<{ + showCancel?: boolean + style?: StyleProp<ViewStyle> +}>) { + const t = useTheme() + + return ( + <DropdownMenu.Portal> + <DropdownMenu.Content sideOffset={5} loop aria-label="Test"> + <View + style={[ + a.rounded_sm, + a.p_xs, + t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25, + t.atoms.shadow_md, + style, + ]}> + {children} + </View> + + {/* Disabled until we can fix positioning + <DropdownMenu.Arrow + className="DropdownMenuArrow" + fill={ + (t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25) + .backgroundColor + } + /> + */} + </DropdownMenu.Content> + </DropdownMenu.Portal> + ) +} + +export function Item({children, label, onPress, ...rest}: ItemProps) { + const t = useTheme() + const {control} = React.useContext(Context) + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + return ( + <DropdownMenu.Item asChild> + <Pressable + {...rest} + className="radix-dropdown-item" + accessibilityHint="" + accessibilityLabel={label} + onPress={e => { + onPress(e) + + /** + * Ported forward from Radix + * @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item + */ + if (!e.defaultPrevented) { + control.close() + } + }} + onFocus={onFocus} + onBlur={onBlur} + // need `flatten` here for Radix compat + style={flatten([ + a.flex_row, + a.align_center, + a.gap_lg, + a.py_sm, + a.rounded_xs, + {minHeight: 32, paddingHorizontal: 10}, + web({outline: 0}), + (hovered || focused) && [ + web({outline: '0 !important'}), + t.name === 'light' + ? t.atoms.bg_contrast_25 + : t.atoms.bg_contrast_50, + ], + ])} + {...web({ + onMouseEnter, + onMouseLeave, + })}> + {children} + </Pressable> + </DropdownMenu.Item> + ) +} + +export function ItemText({children, style}: ItemTextProps) { + const t = useTheme() + return ( + <Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}> + {children} + </Text> + ) +} + +export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) { + const t = useTheme() + return ( + <Comp + size="md" + fill={t.atoms.text_contrast_medium.color} + style={[ + position === 'left' && { + marginLeft: -2, + }, + position === 'right' && { + marginRight: -2, + marginLeft: 12, + }, + ]} + /> + ) +} + +export function Group({children}: GroupProps) { + return children +} + +export function Divider() { + const t = useTheme() + return ( + <DropdownMenu.Separator + style={flatten([ + a.my_xs, + t.atoms.bg_contrast_100, + { + height: 1, + }, + ])} + /> + ) +} diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts new file mode 100644 index 000000000..e710971ee --- /dev/null +++ b/src/components/Menu/types.ts @@ -0,0 +1,99 @@ +import React from 'react' +import { + GestureResponderEvent, + PressableProps, + AccessibilityProps, +} from 'react-native' + +import {Props as SVGIconProps} from '#/components/icons/common' +import * as Dialog from '#/components/Dialog' +import {TextStyleProp, ViewStyleProp} from '#/alf' + +export type ContextType = { + control: Dialog.DialogOuterProps['control'] +} + +export type RadixPassThroughTriggerProps = { + id: string + type: 'button' + disabled: boolean + ['data-disabled']: boolean + ['data-state']: string + ['aria-controls']?: string + ['aria-haspopup']?: boolean + ['aria-expanded']?: AccessibilityProps['aria-expanded'] + onKeyDown: (e: React.KeyboardEvent) => void + /** + * Radix provides this, but we override on web to use `onPress` instead, + * which is less sensitive while scrolling. + */ + onPointerDown: PressableProps['onPointerDown'] +} +export type TriggerProps = { + children(props: TriggerChildProps): React.ReactNode + label: string +} +export type TriggerChildProps = + | { + isNative: true + control: Dialog.DialogOuterProps['control'] + state: { + /** + * Web only, `false` on native + */ + hovered: false + focused: boolean + pressed: boolean + } + /** + * We don't necessarily know what these will be spread on to, so we + * should add props one-by-one. + * + * On web, these properties are applied to a parent `Pressable`, so this + * object is empty. + */ + props: { + onPress: () => void + onFocus: () => void + onBlur: () => void + onPressIn: () => void + onPressOut: () => void + accessibilityLabel: string + } + } + | { + isNative: false + control: Dialog.DialogOuterProps['control'] + state: { + hovered: boolean + focused: boolean + /** + * Native only, `false` on web + */ + pressed: false + } + props: RadixPassThroughTriggerProps & { + onPress: () => void + onFocus: () => void + onBlur: () => void + onMouseEnter: () => void + onMouseLeave: () => void + accessibilityLabel: string + } + } + +export type ItemProps = React.PropsWithChildren< + Omit<PressableProps, 'style'> & + ViewStyleProp & { + label: string + onPress: (e: GestureResponderEvent) => void + } +> + +export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}> +export type ItemIconProps = React.PropsWithChildren<{ + icon: React.ComponentType<SVGIconProps> + position?: 'left' | 'right' +}> + +export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}> diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 2c79d27cf..37d1b700a 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -1,13 +1,12 @@ import React from 'react' -import {View, PressableProps} from 'react-native' +import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useTheme, atoms as a} from '#/alf' -import {Text} from '#/components/Typography' -import {Button} from '#/components/Button' - +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonColor, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' export {useDialogControl as usePromptControl} from '#/components/Dialog' @@ -22,9 +21,12 @@ const Context = React.createContext<{ export function Outer({ children, control, + testID, }: React.PropsWithChildren<{ control: Dialog.DialogOuterProps['control'] + testID?: string }>) { + const {gtMobile} = useBreakpoints() const titleId = React.useId() const descriptionId = React.useId() @@ -34,16 +36,16 @@ export function Outer({ ) return ( - <Dialog.Outer control={control}> + <Dialog.Outer control={control} testID={testID}> <Context.Provider value={context}> <Dialog.Handle /> - <Dialog.Inner + <Dialog.ScrollableInner accessibilityLabelledBy={titleId} accessibilityDescribedBy={descriptionId} - style={{width: 'auto', maxWidth: 400}}> + style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}> {children} - </Dialog.Inner> + </Dialog.ScrollableInner> </Context.Provider> </Dialog.Outer> ) @@ -71,8 +73,18 @@ export function Description({children}: React.PropsWithChildren<{}>) { } export function Actions({children}: React.PropsWithChildren<{}>) { + const {gtMobile} = useBreakpoints() + return ( - <View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}> + <View + style={[ + a.w_full, + a.gap_md, + a.justify_end, + gtMobile + ? [a.flex_row, a.flex_row_reverse, a.justify_start] + : [a.flex_col], + ]}> {children} </View> ) @@ -80,17 +92,29 @@ export function Actions({children}: React.PropsWithChildren<{}>) { export function Cancel({ children, -}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) { + cta, +}: React.PropsWithChildren<{ + /** + * Optional i18n string, used in lieu of `children` for simple buttons. If + * undefined (and `children` is undefined), it will default to "Cancel". + */ + cta?: string +}>) { const {_} = useLingui() + const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() + const onPress = React.useCallback(() => { + close() + }, [close]) + return ( <Button variant="solid" color="secondary" - size="small" - label={_(msg`Cancel`)} - onPress={close}> - {children} + size={gtMobile ? 'small' : 'medium'} + label={cta || _(msg`Cancel`)} + onPress={onPress}> + {children ? children : <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>} </Button> ) } @@ -98,21 +122,70 @@ export function Cancel({ export function Action({ children, onPress, -}: React.PropsWithChildren<{onPress?: () => void}>) { + color = 'primary', + cta, + testID, +}: React.PropsWithChildren<{ + onPress: () => void + color?: ButtonColor + /** + * Optional i18n string, used in lieu of `children` for simple buttons. If + * undefined (and `children` is undefined), it will default to "Confirm". + */ + cta?: string + testID?: string +}>) { const {_} = useLingui() + const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() const handleOnPress = React.useCallback(() => { close() - onPress?.() + onPress() }, [close, onPress]) + return ( <Button variant="solid" - color="primary" - size="small" - label={_(msg`Confirm`)} - onPress={handleOnPress}> - {children} + color={color} + size={gtMobile ? 'small' : 'medium'} + label={cta || _(msg`Confirm`)} + onPress={handleOnPress} + testID={testID}> + {children ? children : <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>} </Button> ) } + +export function Basic({ + control, + title, + description, + cancelButtonCta, + confirmButtonCta, + onConfirm, + confirmButtonColor, +}: React.PropsWithChildren<{ + control: Dialog.DialogOuterProps['control'] + title: string + description: string + cancelButtonCta?: string + confirmButtonCta?: string + onConfirm: () => void + confirmButtonColor?: ButtonColor +}>) { + return ( + <Outer control={control} testID="confirmModal"> + <Title>{title}</Title> + <Description>{description}</Description> + <Actions> + <Action + cta={confirmButtonCta} + onPress={onConfirm} + color={confirmButtonColor} + testID="confirmBtn" + /> + <Cancel cta={cancelButtonCta} /> + </Actions> + </Outer> + ) +} diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx new file mode 100644 index 000000000..dd07cafa3 --- /dev/null +++ b/src/components/ReportDialog/SelectLabelerView.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyLabelerDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export {useDialogControl as useReportDialogControl} from '#/components/Dialog' +import {getLabelingServiceTitle} from '#/lib/moderation' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, useButtonContext} from '#/components/Button' +import {Divider} from '#/components/Divider' +import * as LabelingServiceCard from '#/components/LabelingServiceCard' +import {Text} from '#/components/Typography' +import {ReportDialogProps} from './types' + +export function SelectLabelerView({ + ...props +}: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + onSelectLabeler: (v: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + + return ( + <View style={[a.gap_lg]}> + <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> + <Text style={[a.text_2xl, a.font_bold]}> + <Trans>Select moderator</Trans> + </Text> + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + <Trans>To whom would you like to send this report?</Trans> + </Text> + </View> + + <Divider /> + + <View style={[a.gap_sm]}> + {props.labelers.map(labeler => { + return ( + <Button + key={labeler.creator.did} + label={_(msg`Send report to ${labeler.creator.displayName}`)} + onPress={() => props.onSelectLabeler(labeler.creator.did)}> + <LabelerButton labeler={labeler} /> + </Button> + ) + })} + </View> + </View> + ) +} + +function LabelerButton({ + labeler, +}: { + labeler: AppBskyLabelerDefs.LabelerViewDetailed +}) { + const t = useTheme() + const {hovered, pressed} = useButtonContext() + const interacted = hovered || pressed + + return ( + <LabelingServiceCard.Outer + style={[ + a.p_md, + a.rounded_sm, + t.atoms.bg_contrast_25, + interacted && t.atoms.bg_contrast_50, + ]}> + <LabelingServiceCard.Avatar avatar={labeler.creator.avatar} /> + <LabelingServiceCard.Content> + <LabelingServiceCard.Title + value={getLabelingServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + })} + /> + <Text + style={[t.atoms.text_contrast_medium, a.text_sm, a.font_semibold]}> + @{labeler.creator.handle} + </Text> + </LabelingServiceCard.Content> + </LabelingServiceCard.Outer> + ) +} diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx new file mode 100644 index 000000000..c67698348 --- /dev/null +++ b/src/components/ReportDialog/SelectReportOptionView.tsx @@ -0,0 +1,185 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyLabelerDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {ReportOption, useReportOptions} from '#/lib/moderation/useReportOptions' +import {Link} from '#/components/Link' +import {DMCA_LINK} from '#/components/ReportDialog/const' +export {useDialogControl as useReportDialogControl} from '#/components/Dialog' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import { + Button, + ButtonIcon, + ButtonText, + useButtonContext, +} from '#/components/Button' +import {Divider} from '#/components/Divider' +import { + ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, + ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, +} from '#/components/icons/Chevron' +import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' +import {Text} from '#/components/Typography' +import {ReportDialogProps} from './types' + +export function SelectReportOptionView({ + ...props +}: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + onSelectReportOption: (reportOption: ReportOption) => void + goBack: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const allReportOptions = useReportOptions() + const reportOptions = allReportOptions[props.params.type] + + const i18n = React.useMemo(() => { + let title = _(msg`Report this content`) + let description = _(msg`Why should this content be reviewed?`) + + if (props.params.type === 'account') { + title = _(msg`Report this user`) + description = _(msg`Why should this user be reviewed?`) + } else if (props.params.type === 'post') { + title = _(msg`Report this post`) + description = _(msg`Why should this post be reviewed?`) + } else if (props.params.type === 'list') { + title = _(msg`Report this list`) + description = _(msg`Why should this list be reviewed?`) + } else if (props.params.type === 'feedgen') { + title = _(msg`Report this feed`) + description = _(msg`Why should this feed be reviewed?`) + } + + return { + title, + description, + } + }, [_, props.params.type]) + + return ( + <View style={[a.gap_lg]}> + {props.labelers?.length > 1 ? ( + <Button + size="small" + variant="solid" + color="secondary" + shape="round" + label={_(msg`Go back to previous step`)} + onPress={props.goBack}> + <ButtonIcon icon={ChevronLeft} /> + </Button> + ) : null} + + <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> + <Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text> + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + {i18n.description} + </Text> + </View> + + <Divider /> + + <View style={[a.gap_sm]}> + {reportOptions.map(reportOption => { + return ( + <Button + key={reportOption.reason} + label={_(msg`Create report for ${reportOption.title}`)} + onPress={() => props.onSelectReportOption(reportOption)}> + <ReportOptionButton + title={reportOption.title} + description={reportOption.description} + /> + </Button> + ) + })} + + {(props.params.type === 'post' || props.params.type === 'account') && ( + <View + style={[ + a.flex_row, + a.align_center, + a.justify_between, + a.gap_lg, + a.p_md, + a.pl_lg, + a.rounded_md, + t.atoms.bg_contrast_900, + ]}> + <Text + style={[ + a.flex_1, + t.atoms.text_inverted, + a.italic, + a.leading_snug, + ]}> + <Trans>Need to report a copyright violation?</Trans> + </Text> + <Link + to={DMCA_LINK} + label={_(msg`View details for reporting a copyright violation`)} + size="small" + variant="solid" + color="secondary"> + <ButtonText> + <Trans>View details</Trans> + </ButtonText> + <ButtonIcon position="right" icon={SquareArrowTopRight} /> + </Link> + </View> + )} + </View> + </View> + ) +} + +function ReportOptionButton({ + title, + description, +}: { + title: string + description: string +}) { + const t = useTheme() + const {hovered, pressed} = useButtonContext() + const interacted = hovered || pressed + + return ( + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_between, + a.p_md, + a.rounded_md, + {paddingRight: 70}, + t.atoms.bg_contrast_25, + interacted && t.atoms.bg_contrast_50, + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> + {title} + </Text> + <Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text> + </View> + + <View + style={[ + a.absolute, + a.inset_0, + a.justify_center, + a.pr_md, + {left: 'auto'}, + ]}> + <ChevronRight size="md" fill={t.atoms.text_contrast_low.color} /> + </View> + </View> + ) +} diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx new file mode 100644 index 000000000..d47211c81 --- /dev/null +++ b/src/components/ReportDialog/SubmitView.tsx @@ -0,0 +1,264 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyLabelerDefs} from '@atproto/api' + +import {getLabelingServiceTitle} from '#/lib/moderation' +import {ReportOption} from '#/lib/moderation/useReportOptions' + +import {atoms as a, useTheme, native} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import * as Toggle from '#/components/forms/Toggle' +import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' +import {Loader} from '#/components/Loader' +import * as Toast from '#/view/com/util/Toast' + +import {ReportDialogProps} from './types' +import {getAgent} from '#/state/session' + +export function SubmitView({ + params, + labelers, + selectedLabeler, + selectedReportOption, + goBack, + onSubmitComplete, +}: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + selectedLabeler: string + selectedReportOption: ReportOption + goBack: () => void + onSubmitComplete: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const [details, setDetails] = React.useState<string>('') + const [submitting, setSubmitting] = React.useState<boolean>(false) + const [selectedServices, setSelectedServices] = React.useState<string[]>([ + selectedLabeler, + ]) + const [error, setError] = React.useState('') + + const submit = React.useCallback(async () => { + setSubmitting(true) + setError('') + + const $type = + params.type === 'account' + ? 'com.atproto.admin.defs#repoRef' + : 'com.atproto.repo.strongRef' + const report = { + reasonType: selectedReportOption.reason, + subject: { + $type, + ...params, + }, + reason: details, + } + const results = await Promise.all( + selectedServices.map(did => + getAgent() + .withProxy('atproto_labeler', did) + .createModerationReport(report) + .then( + _ => true, + _ => false, + ), + ), + ) + + setSubmitting(false) + + if (results.includes(true)) { + Toast.show(_(msg`Thank you. Your report has been sent.`)) + onSubmitComplete() + } else { + setError( + _( + msg`There was an issue sending your report. Please check your internet connection.`, + ), + ) + } + }, [ + _, + params, + details, + selectedReportOption, + selectedServices, + onSubmitComplete, + setError, + ]) + + return ( + <View style={[a.gap_2xl]}> + <Button + size="small" + variant="solid" + color="secondary" + shape="round" + label={_(msg`Go back to previous step`)} + onPress={goBack}> + <ButtonIcon icon={ChevronLeft} /> + </Button> + + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_between, + a.gap_lg, + a.p_md, + a.rounded_md, + a.border, + t.atoms.border_contrast_low, + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.text_md, a.font_bold]}> + {selectedReportOption.title} + </Text> + <Text style={[a.leading_tight, {maxWidth: 400}]}> + {selectedReportOption.description} + </Text> + </View> + + <Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} /> + </View> + + <View style={[a.gap_md]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Select the moderation service(s) to report to</Trans> + </Text> + + <Toggle.Group + label="Select mod services" + values={selectedServices} + onChange={setSelectedServices}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + {labelers.map(labeler => { + const title = getLabelingServiceTitle({ + displayName: labeler.creator.displayName, + handle: labeler.creator.handle, + }) + return ( + <Toggle.Item + key={labeler.creator.did} + name={labeler.creator.did} + label={title}> + <LabelerToggle title={title} /> + </Toggle.Item> + ) + })} + </View> + </Toggle.Group> + </View> + <View style={[a.gap_md]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Optionally provide additional information below:</Trans> + </Text> + + <View style={[a.relative, a.w_full]}> + <Dialog.Input + multiline + value={details} + onChangeText={setDetails} + label="Text field" + style={{paddingRight: 60}} + numberOfLines={6} + /> + + <View + style={[ + a.absolute, + a.flex_row, + a.align_center, + a.pr_md, + a.pb_sm, + { + bottom: 0, + right: 0, + }, + ]}> + <CharProgress count={details?.length || 0} /> + </View> + </View> + </View> + + <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}> + {!selectedServices.length || + (error && ( + <Text + style={[ + a.flex_1, + a.italic, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {error ? ( + error + ) : ( + <Trans>You must select at least one labeler for a report</Trans> + )} + </Text> + ))} + + <Button + size="large" + variant="solid" + color="negative" + label={_(msg`Send report`)} + onPress={submit} + disabled={!selectedServices.length}> + <ButtonText> + <Trans>Send report</Trans> + </ButtonText> + {submitting && <ButtonIcon icon={Loader} />} + </Button> + </View> + </View> + ) +} + +function LabelerToggle({title}: {title: string}) { + const t = useTheme() + const ctx = Toggle.useItemContext() + + return ( + <View + style={[ + a.flex_row, + a.align_center, + a.gap_md, + a.p_md, + a.pr_lg, + a.rounded_sm, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ctx.selected && [t.atoms.bg_contrast_50], + ]}> + <Toggle.Checkbox /> + <View + style={[ + a.flex_row, + a.align_center, + a.justify_between, + a.gap_lg, + a.z_10, + ]}> + <Text + style={[ + native({marginTop: 2}), + t.atoms.text_contrast_medium, + ctx.selected && t.atoms.text, + ]}> + {title} + </Text> + </View> + </View> + ) +} diff --git a/src/components/ReportDialog/const.ts b/src/components/ReportDialog/const.ts new file mode 100644 index 000000000..30c9aff88 --- /dev/null +++ b/src/components/ReportDialog/const.ts @@ -0,0 +1 @@ +export const DMCA_LINK = 'https://bsky.social/about/support/copyright' diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx new file mode 100644 index 000000000..c87d32f9e --- /dev/null +++ b/src/components/ReportDialog/index.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import {Pressable, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {ReportOption} from '#/lib/moderation/useReportOptions' +import {useMyLabelersQuery} from '#/state/queries/preferences' +export {useDialogControl as useReportDialogControl} from '#/components/Dialog' + +import {AppBskyLabelerDefs} from '@atproto/api' +import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src' + +import {atoms as a} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' +import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {SelectLabelerView} from './SelectLabelerView' +import {SelectReportOptionView} from './SelectReportOptionView' +import {SubmitView} from './SubmitView' +import {ReportDialogProps} from './types' + +export function ReportDialog(props: ReportDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + + <ReportDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function ReportDialogInner(props: ReportDialogProps) { + const {_} = useLingui() + const { + isLoading: isLabelerLoading, + data: labelers, + error, + } = useMyLabelersQuery() + const isLoading = useDelayedLoading(500, isLabelerLoading) + + const ref = React.useRef<BottomSheetScrollViewMethods>(null) + useOnKeyboardDidShow(() => { + ref.current?.scrollToEnd({animated: true}) + }) + + return ( + <Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}> + {isLoading ? ( + <View style={[a.align_center, {height: 100}]}> + <Loader size="xl" /> + {/* Here to capture focus for a hot sec to prevent flash */} + <Pressable accessible={false} /> + </View> + ) : error || !labelers ? ( + <View> + <Text style={[a.text_md]}> + <Trans>Something went wrong, please try again.</Trans> + </Text> + </View> + ) : ( + <ReportDialogLoaded labelers={labelers} {...props} /> + )} + </Dialog.ScrollableInner> + ) +} + +function ReportDialogLoaded( + props: ReportDialogProps & { + labelers: AppBskyLabelerDefs.LabelerViewDetailed[] + }, +) { + const [selectedLabeler, setSelectedLabeler] = React.useState< + string | undefined + >(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined) + const [selectedReportOption, setSelectedReportOption] = React.useState< + ReportOption | undefined + >() + + if (selectedReportOption && selectedLabeler) { + return ( + <SubmitView + {...props} + selectedLabeler={selectedLabeler} + selectedReportOption={selectedReportOption} + goBack={() => setSelectedReportOption(undefined)} + onSubmitComplete={() => props.control.close()} + /> + ) + } + if (selectedLabeler) { + return ( + <SelectReportOptionView + {...props} + goBack={() => setSelectedLabeler(undefined)} + onSelectReportOption={setSelectedReportOption} + /> + ) + } + return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} /> +} diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts new file mode 100644 index 000000000..0c8a1e077 --- /dev/null +++ b/src/components/ReportDialog/types.ts @@ -0,0 +1,15 @@ +import * as Dialog from '#/components/Dialog' + +export type ReportDialogProps = { + control: Dialog.DialogOuterProps['control'] + params: + | { + type: 'post' | 'list' | 'feedgen' | 'other' + uri: string + cid: string + } + | { + type: 'account' + did: string + } +} diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 068ee99e0..1a14415cf 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,11 +1,15 @@ import React from 'react' import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' -import {atoms as a, TextStyleProp} from '#/alf' +import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf' import {InlineLink} from '#/components/Link' -import {Text} from '#/components/Typography' +import {Text, TextProps} from '#/components/Typography' import {toShortUrl} from 'lib/strings/url-helpers' -import {getAgent} from '#/state/session' +import {TagMenu, useTagMenuControl} from '#/components/TagMenu' +import {isNative} from '#/platform/detection' +import {useInteractionState} from '#/components/hooks/useInteractionState' const WORD_WRAP = {wordWrap: 1} @@ -15,34 +19,24 @@ export function RichText({ style, numberOfLines, disableLinks, - resolveFacets = false, -}: TextStyleProp & { - value: RichTextAPI | string - testID?: string - numberOfLines?: number - disableLinks?: boolean - resolveFacets?: boolean -}) { - const detected = React.useRef(false) - const [richText, setRichText] = React.useState<RichTextAPI>(() => - value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), + selectable, + enableTags = false, + authorHandle, +}: TextStyleProp & + Pick<TextProps, 'selectable'> & { + value: RichTextAPI | string + testID?: string + numberOfLines?: number + disableLinks?: boolean + enableTags?: boolean + authorHandle?: string + }) { + const richText = React.useMemo( + () => + value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), + [value], ) - const styles = [a.leading_normal, style] - - React.useEffect(() => { - if (!resolveFacets) return - - async function detectFacets() { - const rt = new RichTextAPI({text: richText.text}) - await rt.detectFacets(getAgent()) - setRichText(rt) - } - - if (!detected.current) { - detected.current = true - detectFacets() - } - }, [richText, setRichText, resolveFacets]) + const styles = [a.leading_snug, flatten(style)] const {text, facets} = richText @@ -50,6 +44,7 @@ export function RichText({ if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) { return ( <Text + selectable={selectable} testID={testID} style={[ { @@ -65,6 +60,7 @@ export function RichText({ } return ( <Text + selectable={selectable} testID={testID} style={styles} numberOfLines={numberOfLines} @@ -81,6 +77,7 @@ export function RichText({ for (const segment of richText.segments()) { const link = segment.link const mention = segment.mention + const tag = segment.tag if ( mention && AppBskyRichtextFacet.validateMention(mention).success && @@ -88,6 +85,7 @@ export function RichText({ ) { els.push( <InlineLink + selectable={selectable} key={key} to={`/profile/${mention.did}`} style={[...styles, {pointerEvents: 'auto'}]} @@ -102,16 +100,32 @@ export function RichText({ } else { els.push( <InlineLink + selectable={selectable} key={key} to={link.uri} style={[...styles, {pointerEvents: 'auto'}]} // @ts-ignore TODO - dataSet={WORD_WRAP} - warnOnMismatchingLabel> + dataSet={WORD_WRAP}> {toShortUrl(segment.text)} </InlineLink>, ) } + } else if ( + !disableLinks && + enableTags && + tag && + AppBskyRichtextFacet.validateTag(tag).success + ) { + els.push( + <RichTextTag + key={key} + text={segment.text} + tag={tag.tag} + style={styles} + selectable={selectable} + authorHandle={authorHandle} + />, + ) } else { els.push(segment.text) } @@ -120,6 +134,7 @@ export function RichText({ return ( <Text + selectable={selectable} testID={testID} style={styles} numberOfLines={numberOfLines} @@ -129,3 +144,81 @@ export function RichText({ </Text> ) } + +function RichTextTag({ + text, + tag, + style, + selectable, + authorHandle, +}: { + text: string + tag: string + selectable?: boolean + authorHandle?: string +} & TextStyleProp) { + const t = useTheme() + const {_} = useLingui() + const control = useTagMenuControl() + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + const open = React.useCallback(() => { + control.open() + }, [control]) + + /* + * N.B. On web, this is wrapped in another pressable comopnent with a11y + * labels, etc. That's why only some of these props are applied here. + */ + + return ( + <React.Fragment> + <TagMenu control={control} tag={tag} authorHandle={authorHandle}> + <Text + selectable={selectable} + {...native({ + accessibilityLabel: _(msg`Hashtag: #${tag}`), + accessibilityHint: _(msg`Click here to open tag menu for #${tag}`), + accessibilityRole: isNative ? 'button' : undefined, + onPress: open, + onPressIn: onPressIn, + onPressOut: onPressOut, + })} + {...web({ + onMouseEnter: onHoverIn, + onMouseLeave: onHoverOut, + })} + // @ts-ignore + onFocus={onFocus} + onBlur={onBlur} + style={[ + style, + { + pointerEvents: 'auto', + color: t.palette.primary_500, + }, + web({ + cursor: 'pointer', + }), + (hovered || focused || pressed) && { + ...web({outline: 0}), + textDecorationLine: 'underline', + textDecorationColor: t.palette.primary_500, + }, + ]}> + {text} + </Text> + </TagMenu> + </React.Fragment> + ) +} diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx new file mode 100644 index 000000000..0ed703667 --- /dev/null +++ b/src/components/TagMenu/index.tsx @@ -0,0 +1,277 @@ +import React from 'react' +import {View} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {atoms as a, native, useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' +import {Button, ButtonText} from '#/components/Button' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {Divider} from '#/components/Divider' +import {Link} from '#/components/Link' +import {makeSearchLink} from '#/lib/routes/links' +import {NavigationProp} from '#/lib/routes/types' +import { + usePreferencesQuery, + useUpsertMutedWordsMutation, + useRemoveMutedWordMutation, +} from '#/state/queries/preferences' +import {Loader} from '#/components/Loader' +import {isInvalidHandle} from '#/lib/strings/handles' + +export function useTagMenuControl() { + return Dialog.useDialogControl() +} + +export function TagMenu({ + children, + control, + tag, + authorHandle, +}: React.PropsWithChildren<{ + control: Dialog.DialogOuterProps['control'] + /** + * This should be the sanitized tag value from the facet itself, not the + * "display" value with a leading `#`. + */ + tag: string + authorHandle?: string +}>) { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + const {isLoading: isPreferencesLoading, data: preferences} = + usePreferencesQuery() + const { + mutateAsync: upsertMutedWord, + variables: optimisticUpsert, + reset: resetUpsert, + } = useUpsertMutedWordsMutation() + const { + mutateAsync: removeMutedWord, + variables: optimisticRemove, + reset: resetRemove, + } = useRemoveMutedWordMutation() + const displayTag = '#' + tag + + const isMuted = Boolean( + (preferences?.moderationPrefs.mutedWords?.find( + m => m.value === tag && m.targets.includes('tag'), + ) ?? + optimisticUpsert?.find( + m => m.value === tag && m.targets.includes('tag'), + )) && + !(optimisticRemove?.value === tag), + ) + + return ( + <> + {children} + + <Dialog.Outer control={control}> + <Dialog.Handle /> + + <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}> + {isPreferencesLoading ? ( + <View style={[a.w_full, a.align_center]}> + <Loader size="lg" /> + </View> + ) : ( + <> + <View + style={[ + a.rounded_md, + a.border, + a.mb_md, + t.atoms.border_contrast_low, + t.atoms.bg_contrast_25, + ]}> + <Link + label={_(msg`Search for all posts with tag ${displayTag}`)} + to={makeSearchLink({query: displayTag})} + onPress={e => { + e.preventDefault() + + control.close(() => { + navigation.push('Hashtag', { + tag: encodeURIComponent(tag), + }) + }) + + return false + }}> + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_start, + a.gap_md, + a.px_lg, + a.py_md, + ]}> + <Search size="lg" style={[t.atoms.text_contrast_medium]} /> + <Text + numberOfLines={1} + ellipsizeMode="middle" + style={[ + a.flex_1, + a.text_md, + a.font_bold, + native({top: 2}), + t.atoms.text_contrast_medium, + ]}> + <Trans> + See{' '} + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> + {displayTag} + </Text>{' '} + posts + </Trans> + </Text> + </View> + </Link> + + {authorHandle && !isInvalidHandle(authorHandle) && ( + <> + <Divider /> + + <Link + label={_( + msg`Search for all posts by @${authorHandle} with tag ${displayTag}`, + )} + to={makeSearchLink({ + query: displayTag, + from: authorHandle, + })} + onPress={e => { + e.preventDefault() + + control.close(() => { + navigation.push('Hashtag', { + tag: encodeURIComponent(tag), + author: authorHandle, + }) + }) + + return false + }}> + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_start, + a.gap_md, + a.px_lg, + a.py_md, + ]}> + <Person + size="lg" + style={[t.atoms.text_contrast_medium]} + /> + <Text + numberOfLines={1} + ellipsizeMode="middle" + style={[ + a.flex_1, + a.text_md, + a.font_bold, + native({top: 2}), + t.atoms.text_contrast_medium, + ]}> + <Trans> + See{' '} + <Text + style={[a.text_md, a.font_bold, t.atoms.text]}> + {displayTag} + </Text>{' '} + posts by this user + </Trans> + </Text> + </View> + </Link> + </> + )} + + {preferences ? ( + <> + <Divider /> + + <Button + label={ + isMuted + ? _(msg`Unmute all ${displayTag} posts`) + : _(msg`Mute all ${displayTag} posts`) + } + onPress={() => { + control.close(() => { + if (isMuted) { + resetUpsert() + removeMutedWord({ + value: tag, + targets: ['tag'], + }) + } else { + resetRemove() + upsertMutedWord([{value: tag, targets: ['tag']}]) + } + }) + }}> + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.justify_start, + a.gap_md, + a.px_lg, + a.py_md, + ]}> + <Mute + size="lg" + style={[t.atoms.text_contrast_medium]} + /> + <Text + numberOfLines={1} + ellipsizeMode="middle" + style={[ + a.flex_1, + a.text_md, + a.font_bold, + native({top: 2}), + t.atoms.text_contrast_medium, + ]}> + {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '} + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> + {displayTag} + </Text>{' '} + <Trans>posts</Trans> + </Text> + </View> + </Button> + </> + ) : null} + </View> + + <Button + label={_(msg`Close this dialog`)} + size="small" + variant="ghost" + color="secondary" + onPress={() => control.close()}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + </> + )} + </Dialog.Inner> + </Dialog.Outer> + </> + ) +} diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx new file mode 100644 index 000000000..433622386 --- /dev/null +++ b/src/components/TagMenu/index.web.tsx @@ -0,0 +1,149 @@ +import React from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {isInvalidHandle} from '#/lib/strings/handles' +import {EventStopper} from '#/view/com/util/EventStopper' +import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' +import {NavigationProp} from '#/lib/routes/types' +import { + usePreferencesQuery, + useUpsertMutedWordsMutation, + useRemoveMutedWordMutation, +} from '#/state/queries/preferences' +import {enforceLen} from '#/lib/strings/helpers' +import {web} from '#/alf' +import * as Dialog from '#/components/Dialog' + +export function useTagMenuControl(): Dialog.DialogControlProps { + return { + id: '', + // @ts-ignore + ref: null, + open: () => { + throw new Error(`TagMenu controls are only available on native platforms`) + }, + close: () => { + throw new Error(`TagMenu controls are only available on native platforms`) + }, + } +} + +export function TagMenu({ + children, + tag, + authorHandle, +}: React.PropsWithChildren<{ + /** + * This should be the sanitized tag value from the facet itself, not the + * "display" value with a leading `#`. + */ + tag: string + authorHandle?: string +}>) { + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + const {data: preferences} = usePreferencesQuery() + const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = + useUpsertMutedWordsMutation() + const {mutateAsync: removeMutedWord, variables: optimisticRemove} = + useRemoveMutedWordMutation() + const isMuted = Boolean( + (preferences?.moderationPrefs.mutedWords?.find( + m => m.value === tag && m.targets.includes('tag'), + ) ?? + optimisticUpsert?.find( + m => m.value === tag && m.targets.includes('tag'), + )) && + !(optimisticRemove?.value === tag), + ) + const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') + + const dropdownItems = React.useMemo(() => { + return [ + { + label: _(msg`See ${truncatedTag} posts`), + onPress() { + navigation.push('Hashtag', { + tag: encodeURIComponent(tag), + }) + }, + testID: 'tagMenuSearch', + icon: { + ios: { + name: 'magnifyingglass', + }, + android: '', + web: 'magnifying-glass', + }, + }, + authorHandle && + !isInvalidHandle(authorHandle) && { + label: _(msg`See ${truncatedTag} posts by user`), + onPress() { + navigation.push('Hashtag', { + tag: encodeURIComponent(tag), + author: authorHandle, + }) + }, + testID: 'tagMenuSearchByUser', + icon: { + ios: { + name: 'magnifyingglass', + }, + android: '', + web: ['far', 'user'], + }, + }, + preferences && { + label: 'separator', + }, + preferences && { + label: isMuted + ? _(msg`Unmute ${truncatedTag}`) + : _(msg`Mute ${truncatedTag}`), + onPress() { + if (isMuted) { + removeMutedWord({value: tag, targets: ['tag']}) + } else { + upsertMutedWord([{value: tag, targets: ['tag']}]) + } + }, + testID: 'tagMenuMute', + icon: { + ios: { + name: 'speaker.slash', + }, + android: 'ic_menu_sort_alphabetically', + web: isMuted ? 'eye' : ['far', 'eye-slash'], + }, + }, + ].filter(Boolean) + }, [ + _, + authorHandle, + isMuted, + navigation, + preferences, + tag, + truncatedTag, + upsertMutedWord, + removeMutedWord, + ]) + + return ( + <EventStopper> + <NativeDropdown + accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)} + accessibilityHint="" + // @ts-ignore + items={dropdownItems} + triggerStyle={web({ + textAlign: 'left', + })}> + {children} + </NativeDropdown> + </EventStopper> + ) +} diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index b34f51018..f8b3ad1bd 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -1,7 +1,21 @@ import React from 'react' -import {Text as RNText, TextStyle, TextProps} from 'react-native' +import { + Text as RNText, + StyleProp, + TextStyle, + TextProps as RNTextProps, +} from 'react-native' +import {UITextView} from 'react-native-ui-text-view' import {useTheme, atoms, web, flatten} from '#/alf' +import {isIOS, isNative} from '#/platform/detection' + +export type TextProps = RNTextProps & { + /** + * Lets the user select text, to use the native copy and paste functionality. + */ + selectable?: boolean +} /** * Util to calculate lineHeight from a text size atom and a leading atom @@ -25,17 +39,17 @@ export function leading< * If the `lineHeight` value is > 2, we assume it's an absolute value and * returns it as-is. */ -function normalizeTextStyles(styles: TextStyle[]) { +export function normalizeTextStyles(styles: StyleProp<TextStyle>) { const s = flatten(styles) // should always be defined on these components const fontSize = s.fontSize || atoms.text_md.fontSize if (s?.lineHeight) { - if (s.lineHeight <= 2) { + if (s.lineHeight !== 0 && s.lineHeight <= 2) { s.lineHeight = Math.round(fontSize * s.lineHeight) } - } else { - s.lineHeight = fontSize + } else if (!isNative) { + s.lineHeight = s.fontSize } return s @@ -44,27 +58,24 @@ function normalizeTextStyles(styles: TextStyle[]) { /** * Our main text component. Use this most of the time. */ -export function Text({style, ...rest}: TextProps) { +export function Text({style, selectable, ...rest}: TextProps) { const t = useTheme() const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)]) - return <RNText style={s} {...rest} /> + return selectable && isIOS ? ( + <UITextView style={s} {...rest} /> + ) : ( + <RNText selectable={selectable} style={s} {...rest} /> + ) } export function createHeadingElement({level}: {level: number}) { return function HeadingElement({style, ...rest}: TextProps) { - const t = useTheme() const attr = web({ role: 'heading', 'aria-level': level, }) || {} - return ( - <RNText - {...attr} - {...rest} - style={normalizeTextStyles([t.atoms.text, flatten(style)])} - /> - ) + return <Text {...attr} {...rest} style={style} /> } } @@ -78,21 +89,15 @@ export const H4 = createHeadingElement({level: 4}) export const H5 = createHeadingElement({level: 5}) export const H6 = createHeadingElement({level: 6}) export function P({style, ...rest}: TextProps) { - const t = useTheme() const attr = web({ role: 'paragraph', }) || {} return ( - <RNText + <Text {...attr} {...rest} - style={normalizeTextStyles([ - atoms.text_md, - atoms.leading_normal, - t.atoms.text, - flatten(style), - ])} + style={[atoms.text_md, atoms.leading_normal, flatten(style)]} /> ) } diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx new file mode 100644 index 000000000..4a3e96e56 --- /dev/null +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' +import {View} from 'react-native' + +import * as Dialog from '#/components/Dialog' +import {Text} from '../Typography' +import {DateInput} from '#/view/com/util/forms/DateInput' +import {logger} from '#/logger' +import { + usePreferencesQuery, + usePreferencesSetBirthDateMutation, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {Button, ButtonIcon, ButtonText} from '../Button' +import {atoms as a, useTheme} from '#/alf' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {cleanError} from '#/lib/strings/errors' +import {isIOS, isWeb} from '#/platform/detection' +import {Loader} from '#/components/Loader' + +export function BirthDateSettingsDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + const t = useTheme() + const {_} = useLingui() + const {isLoading, error, data: preferences} = usePreferencesQuery() + + return ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + + <Dialog.ScrollableInner label={_(msg`My Birthday`)}> + <View style={[a.gap_sm, a.pb_lg]}> + <Text style={[a.text_2xl, 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> + </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} /> + )} + + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +function BirthdayInner({ + control, + preferences, +}: { + control: Dialog.DialogControlProps + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const [date, setDate] = React.useState(preferences.birthDate || new Date()) + const { + isPending, + isError, + error, + mutateAsync: setBirthDate, + } = usePreferencesSetBirthDateMutation() + const hasChanged = date !== preferences.birthDate + + const onSave = React.useCallback(async () => { + try { + // skip if date is the same + if (hasChanged) { + await setBirthDate({birthDate: date}) + } + control.close() + } catch (e: any) { + logger.error(`setBirthDate failed`, {message: e.message}) + } + }, [date, setBirthDate, control, hasChanged]) + + return ( + <View style={a.gap_lg} testID="birthDateSettingsDialog"> + <View style={isIOS && [a.w_full, a.align_center]}> + <DateInput + handleAsUTC + testID="birthdayInput" + value={date} + onChange={setDate} + buttonType="default-light" + buttonStyle={[a.rounded_sm]} + buttonLabelType="lg" + accessibilityLabel={_(msg`Birthday`)} + accessibilityHint={_(msg`Enter your birth date`)} + accessibilityLabelledBy="birthDate" + /> + </View> + + {isError ? ( + <ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} /> + ) : undefined} + + <View style={isWeb && [a.flex_row, a.justify_end]}> + <Button + label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)} + size="medium" + onPress={onSave} + variant="solid" + color="primary"> + <ButtonText> + {hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>} + </ButtonText> + {isPending && <ButtonIcon icon={Loader} />} + </Button> + </View> + </View> + ) +} diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx new file mode 100644 index 000000000..87bd5c2ed --- /dev/null +++ b/src/components/dialogs/Context.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +import * as Dialog from '#/components/Dialog' + +type Control = Dialog.DialogOuterProps['control'] + +type ControlsContext = { + mutedWordsDialogControl: Control +} + +const ControlsContext = React.createContext({ + mutedWordsDialogControl: {} as Control, +}) + +export function useGlobalDialogsControlContext() { + return React.useContext(ControlsContext) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const mutedWordsDialogControl = Dialog.useDialogControl() + const ctx = React.useMemo<ControlsContext>( + () => ({mutedWordsDialogControl}), + [mutedWordsDialogControl], + ) + + return ( + <ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider> + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx new file mode 100644 index 000000000..46f319adf --- /dev/null +++ b/src/components/dialogs/MutedWords.tsx @@ -0,0 +1,376 @@ +import React from 'react' +import {Keyboard, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' + +import { + usePreferencesQuery, + useUpsertMutedWordsMutation, + useRemoveMutedWordMutation, +} from '#/state/queries/preferences' +import {isNative} from '#/platform/detection' +import { + atoms as a, + useTheme, + useBreakpoints, + ViewStyleProp, + web, + native, +} from '#/alf' +import {Text} from '#/components/Typography' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' +import {Divider} from '#/components/Divider' +import {Loader} from '#/components/Loader' +import {logger} from '#/logger' +import * as Dialog from '#/components/Dialog' +import * as Toggle from '#/components/forms/Toggle' +import * as Prompt from '#/components/Prompt' + +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' + +export function MutedWordsDialog() { + const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() + return ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + <MutedWordsInner control={control} /> + </Dialog.Outer> + ) +} + +function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const { + isLoading: isPreferencesLoading, + data: preferences, + error: preferencesError, + } = usePreferencesQuery() + const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() + const [field, setField] = React.useState('') + const [options, setOptions] = React.useState(['content']) + const [error, setError] = React.useState('') + + const submit = React.useCallback(async () => { + const sanitizedValue = sanitizeMutedWordValue(field) + const targets = ['tag', options.includes('content') && 'content'].filter( + Boolean, + ) as AppBskyActorDefs.MutedWord['targets'] + + if (!sanitizedValue || !targets.length) { + setField('') + setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) + return + } + + try { + // send raw value and rely on SDK as sanitization source of truth + await addMutedWord([{value: field, targets}]) + setField('') + } catch (e: any) { + logger.error(`Failed to save muted word`, {message: e.message}) + setError(e.message) + } + }, [_, field, options, addMutedWord, setField]) + + return ( + <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> + <View onTouchStart={Keyboard.dismiss}> + <Text + style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}> + <Trans>Add muted words and tags</Trans> + </Text> + <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Posts can be muted based on their text, their tags, or both. + </Trans> + </Text> + + <View style={[a.pb_lg]}> + <Dialog.Input + autoCorrect={false} + autoCapitalize="none" + autoComplete="off" + label={_(msg`Enter a word or tag`)} + placeholder={_(msg`Enter a word or tag`)} + value={field} + onChangeText={value => { + if (error) { + setError('') + } + setField(value) + }} + onSubmitEditing={submit} + /> + + <Toggle.Group + label={_(msg`Toggle between muted word options.`)} + type="radio" + values={options} + onChange={setOptions}> + <View + style={[ + a.pt_sm, + a.py_sm, + a.flex_row, + a.align_center, + a.gap_sm, + a.flex_wrap, + ]}> + <Toggle.Item + label={_(msg`Mute this word in post text and tags`)} + name="content" + style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}> + <TargetToggle> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Toggle.Radio /> + <Toggle.Label> + <Trans>Mute in text & tags</Trans> + </Toggle.Label> + </View> + <PageText size="sm" /> + </TargetToggle> + </Toggle.Item> + + <Toggle.Item + label={_(msg`Mute this word in tags only`)} + name="tag" + style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}> + <TargetToggle> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <Toggle.Radio /> + <Toggle.Label> + <Trans>Mute in tags only</Trans> + </Toggle.Label> + </View> + <Hashtag size="sm" /> + </TargetToggle> + </Toggle.Item> + + <Button + disabled={isPending || !field} + label={_(msg`Add mute word for configured settings`)} + size="small" + color="primary" + variant="solid" + style={[!gtMobile && [a.w_full, a.flex_0]]} + onPress={submit}> + <ButtonText> + <Trans>Add</Trans> + </ButtonText> + <ButtonIcon icon={isPending ? Loader : Plus} /> + </Button> + </View> + </Toggle.Group> + + {error && ( + <View + style={[ + a.mb_lg, + a.flex_row, + a.rounded_sm, + a.p_md, + a.mb_xs, + t.atoms.bg_contrast_25, + { + backgroundColor: t.palette.negative_400, + }, + ]}> + <Text + style={[ + a.italic, + {color: t.palette.white}, + native({marginTop: 2}), + ]}> + {error} + </Text> + </View> + )} + + <Text + style={[ + a.pt_xs, + a.text_sm, + a.italic, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + <Trans> + We recommend avoiding common words that appear in many posts, + since it can result in no posts being shown. + </Trans> + </Text> + </View> + + <Divider /> + + <View style={[a.pt_2xl]}> + <Text + style={[ + a.text_md, + a.font_bold, + a.pb_md, + t.atoms.text_contrast_high, + ]}> + <Trans>Your muted words</Trans> + </Text> + + {isPreferencesLoading ? ( + <Loader /> + ) : preferencesError || !preferences ? ( + <View + style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> + <Text style={[a.italic, t.atoms.text_contrast_high]}> + <Trans> + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + </Trans> + </Text> + </View> + ) : preferences.moderationPrefs.mutedWords.length ? ( + [...preferences.moderationPrefs.mutedWords] + .reverse() + .map((word, i) => ( + <MutedWordRow + key={word.value + i} + word={word} + style={[i % 2 === 0 && t.atoms.bg_contrast_25]} + /> + )) + ) : ( + <View + style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> + <Text style={[a.italic, t.atoms.text_contrast_high]}> + <Trans>You haven't muted any words or tags yet</Trans> + </Text> + </View> + )} + </View> + + {isNative && <View style={{height: 20}} />} + </View> + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +function MutedWordRow({ + style, + word, +}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) { + const t = useTheme() + const {_} = useLingui() + const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() + const control = Prompt.usePromptControl() + + const remove = React.useCallback(async () => { + control.close() + removeMutedWord(word) + }, [removeMutedWord, word, control]) + + return ( + <> + <Prompt.Basic + control={control} + title={_(msg`Are you sure?`)} + description={_( + msg`This will delete ${word.value} from your muted words. You can always add it back later.`, + )} + onConfirm={remove} + confirmButtonCta={_(msg`Remove`)} + confirmButtonColor="negative" + /> + + <View + style={[ + a.py_md, + a.px_lg, + a.flex_row, + a.align_center, + a.justify_between, + a.rounded_md, + a.gap_md, + style, + ]}> + <Text + style={[ + a.flex_1, + a.leading_snug, + a.w_full, + a.font_bold, + t.atoms.text_contrast_high, + web({ + overflowWrap: 'break-word', + wordBreak: 'break-word', + }), + ]}> + {word.value} + </Text> + + <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}> + {word.targets.map(target => ( + <View + key={target} + style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}> + <Text + style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}> + {target === 'content' ? _(msg`text`) : _(msg`tag`)} + </Text> + </View> + ))} + + <Button + label={_(msg`Remove mute word from your list`)} + size="tiny" + shape="round" + variant="ghost" + color="secondary" + onPress={() => control.open()} + style={[a.ml_sm]}> + <ButtonIcon icon={isPending ? Loader : X} /> + </Button> + </View> + </View> + </> + ) +} + +function TargetToggle({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const ctx = Toggle.useItemContext() + const {gtMobile} = useBreakpoints() + return ( + <View + style={[ + a.flex_row, + a.align_center, + a.justify_between, + a.gap_xs, + a.flex_1, + a.py_sm, + a.px_sm, + gtMobile && a.px_md, + a.rounded_sm, + t.atoms.bg_contrast_50, + (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100, + ctx.selected && [ + { + backgroundColor: + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975, + }, + ], + ctx.disabled && { + opacity: 0.8, + }, + ]}> + {children} + </View> + ) +} diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx index 83fa285f5..700d15e6d 100644 --- a/src/components/forms/DateField/index.android.tsx +++ b/src/components/forms/DateField/index.android.tsx @@ -1,20 +1,11 @@ import React from 'react' -import {View, Pressable} from 'react-native' -import DateTimePicker, { - BaseProps as DateTimePickerProps, -} from '@react-native-community/datetimepicker' - -import {useTheme, atoms} from '#/alf' -import {Text} from '#/components/Typography' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import * as TextField from '#/components/forms/TextField' -import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' +import DatePicker from 'react-native-date-picker' +import {useTheme} from '#/alf' import {DateFieldProps} from '#/components/forms/DateField/types' -import { - localizeDate, - toSimpleDateString, -} from '#/components/forms/DateField/utils' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import * as TextField from '#/components/forms/TextField' +import {DateFieldButton} from './index.shared' export * as utils from '#/components/forms/DateField/utils' export const Label = TextField.Label @@ -25,84 +16,55 @@ export function DateField({ label, isInvalid, testID, + accessibilityHint, }: DateFieldProps) { const t = useTheme() const [open, setOpen] = React.useState(false) - const { - state: pressed, - onIn: onPressIn, - onOut: onPressOut, - } = useInteractionState() - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - const {chromeFocus, chromeError, chromeErrorHover} = - TextField.useSharedInputStyles() - - const onChangeInternal = React.useCallback< - Required<DateTimePickerProps>['onChange'] - >( - (_event, date) => { + const onChangeInternal = React.useCallback( + (date: Date) => { setOpen(false) - if (date) { - const formatted = toSimpleDateString(date) - onChangeDate(formatted) - } + const formatted = toSimpleDateString(date) + onChangeDate(formatted) }, [onChangeDate, setOpen], ) - return ( - <View style={[atoms.relative, atoms.w_full]}> - <Pressable - aria-label={label} - accessibilityLabel={label} - accessibilityHint={undefined} - onPress={() => setOpen(true)} - onPressIn={onPressIn} - onPressOut={onPressOut} - onFocus={onFocus} - onBlur={onBlur} - style={[ - { - paddingTop: 16, - paddingBottom: 16, - borderColor: 'transparent', - borderWidth: 2, - }, - atoms.flex_row, - atoms.flex_1, - atoms.w_full, - atoms.px_lg, - atoms.rounded_sm, - t.atoms.bg_contrast_50, - focused || pressed ? chromeFocus : {}, - isInvalid ? chromeError : {}, - isInvalid && (focused || pressed) ? chromeErrorHover : {}, - ]}> - <TextField.Icon icon={CalendarDays} /> + const onPress = React.useCallback(() => { + setOpen(true) + }, []) - <Text - style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}> - {localizeDate(value)} - </Text> - </Pressable> + const onCancel = React.useCallback(() => { + setOpen(false) + }, []) + + return ( + <> + <DateFieldButton + label={label} + value={value} + onPress={onPress} + isInvalid={isInvalid} + accessibilityHint={accessibilityHint} + /> {open && ( - <DateTimePicker + <DatePicker + modal + open + timeZoneOffsetInMinutes={0} + theme={t.name === 'light' ? 'light' : 'dark'} + date={new Date(value)} + onConfirm={onChangeInternal} + onCancel={onCancel} + mode="date" + testID={`${testID}-datepicker`} aria-label={label} accessibilityLabel={label} - accessibilityHint={undefined} - testID={`${testID}-datepicker`} - mode="date" - timeZoneName={'Etc/UTC'} - display="spinner" - // @ts-ignore applies in iOS only -prf - themeVariant={t.name === 'dark' ? 'dark' : 'light'} - value={new Date(value)} - onChange={onChangeInternal} + accessibilityHint={accessibilityHint} /> )} - </View> + </> ) } diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx new file mode 100644 index 000000000..1f54bdc8b --- /dev/null +++ b/src/components/forms/DateField/index.shared.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import {Pressable, View} from 'react-native' + +import {android, atoms as a, useTheme, web} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' +import {Text} from '#/components/Typography' +import {localizeDate} from './utils' + +// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press +// iOS: open a dialog with an inline date picker +// Android: open the date picker modal + +export function DateFieldButton({ + label, + value, + onPress, + isInvalid, + accessibilityHint, +}: { + label: string + value: string + onPress: () => void + isInvalid?: boolean + accessibilityHint?: string +}) { + const t = useTheme() + + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const {chromeHover, chromeFocus, chromeError, chromeErrorHover} = + TextField.useSharedInputStyles() + + return ( + <View + style={[a.relative, a.w_full]} + {...web({ + onMouseOver: onHoverIn, + onMouseOut: onHoverOut, + })}> + <Pressable + aria-label={label} + accessibilityLabel={label} + accessibilityHint={accessibilityHint} + onPress={onPress} + onPressIn={onPressIn} + onPressOut={onPressOut} + onFocus={onFocus} + onBlur={onBlur} + style={[ + { + paddingTop: 12, + paddingBottom: 12, + paddingLeft: 14, + paddingRight: 14, + borderColor: 'transparent', + borderWidth: 2, + }, + android({ + minHeight: 57.5, + }), + a.flex_row, + a.flex_1, + a.w_full, + a.rounded_sm, + t.atoms.bg_contrast_25, + a.align_center, + hovered ? chromeHover : {}, + focused || pressed ? chromeFocus : {}, + isInvalid || isInvalid ? chromeError : {}, + (isInvalid || isInvalid) && (hovered || focused) + ? chromeErrorHover + : {}, + ]}> + <TextField.Icon icon={CalendarDays} /> + <Text + style={[ + a.text_md, + a.pl_xs, + t.atoms.text, + {lineHeight: a.text_md.fontSize * 1.1875}, + ]}> + {localizeDate(value)} + </Text> + </Pressable> + </View> + ) +} diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx index c359a9d46..5662bb594 100644 --- a/src/components/forms/DateField/index.tsx +++ b/src/components/forms/DateField/index.tsx @@ -1,13 +1,16 @@ import React from 'react' import {View} from 'react-native' -import DateTimePicker, { - DateTimePickerEvent, -} from '@react-native-community/datetimepicker' +import DatePicker from 'react-native-date-picker' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {useTheme, atoms} from '#/alf' -import * as TextField from '#/components/forms/TextField' -import {toSimpleDateString} from '#/components/forms/DateField/utils' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' import {DateFieldProps} from '#/components/forms/DateField/types' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import * as TextField from '#/components/forms/TextField' +import {DateFieldButton} from './index.shared' export * as utils from '#/components/forms/DateField/utils' export const Label = TextField.Label @@ -24,11 +27,15 @@ export function DateField({ onChangeDate, testID, label, + isInvalid, + accessibilityHint, }: DateFieldProps) { + const {_} = useLingui() const t = useTheme() + const control = Dialog.useDialogControl() const onChangeInternal = React.useCallback( - (event: DateTimePickerEvent, date: Date | undefined) => { + (date: Date | undefined) => { if (date) { const formatted = toSimpleDateString(date) onChangeDate(formatted) @@ -38,19 +45,44 @@ export function DateField({ ) return ( - <View style={[atoms.relative, atoms.w_full]}> - <DateTimePicker - aria-label={label} - accessibilityLabel={label} - accessibilityHint={undefined} - testID={`${testID}-datepicker`} - mode="date" - timeZoneName={'Etc/UTC'} - display="spinner" - themeVariant={t.name === 'dark' ? 'dark' : 'light'} - value={new Date(value)} - onChange={onChangeInternal} + <> + <DateFieldButton + label={label} + value={value} + onPress={control.open} + isInvalid={isInvalid} + accessibilityHint={accessibilityHint} /> - </View> + <Dialog.Outer control={control} testID={testID}> + <Dialog.Handle /> + <Dialog.Inner label={label}> + <View style={a.gap_lg}> + <View style={[a.relative, a.w_full, a.align_center]}> + <DatePicker + timeZoneOffsetInMinutes={0} + theme={t.name === 'light' ? 'light' : 'dark'} + date={new Date(value)} + onDateChange={onChangeInternal} + mode="date" + testID={`${testID}-datepicker`} + aria-label={label} + accessibilityLabel={label} + accessibilityHint={accessibilityHint} + /> + </View> + <Button + label={_(msg`Done`)} + onPress={() => control.close()} + size="medium" + color="primary" + variant="solid"> + <ButtonText> + <Trans>Done</Trans> + </ButtonText> + </Button> + </View> + </Dialog.Inner> + </Dialog.Outer> + </> ) } diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx index 32f38a5d1..982d32711 100644 --- a/src/components/forms/DateField/index.web.tsx +++ b/src/components/forms/DateField/index.web.tsx @@ -1,11 +1,12 @@ import React from 'react' -import {TextInput, TextInputProps, StyleSheet} from 'react-native' +import {StyleSheet, TextInput, TextInputProps} from 'react-native' // @ts-ignore import {unstable_createElement} from 'react-native-web' -import * as TextField from '#/components/forms/TextField' -import {toSimpleDateString} from '#/components/forms/DateField/utils' import {DateFieldProps} from '#/components/forms/DateField/types' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import * as TextField from '#/components/forms/TextField' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' export * as utils from '#/components/forms/DateField/utils' export const Label = TextField.Label @@ -37,6 +38,7 @@ export function DateField({ label, isInvalid, testID, + accessibilityHint, }: DateFieldProps) { const handleOnChange = React.useCallback( (e: any) => { @@ -52,12 +54,14 @@ export function DateField({ return ( <TextField.Root isInvalid={isInvalid}> + <TextField.Icon icon={CalendarDays} /> <Input value={value} label={label} onChange={handleOnChange} onChangeText={() => {}} testID={testID} + accessibilityHint={accessibilityHint} /> </TextField.Root> ) diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts index 129f5672d..5400cf903 100644 --- a/src/components/forms/DateField/types.ts +++ b/src/components/forms/DateField/types.ts @@ -4,4 +4,5 @@ export type DateFieldProps = { label: string isInvalid?: boolean testID?: string + accessibilityHint?: string } diff --git a/src/components/forms/FormError.tsx b/src/components/forms/FormError.tsx new file mode 100644 index 000000000..8ab6e3f35 --- /dev/null +++ b/src/components/forms/FormError.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Text} from '#/components/Typography' + +export function FormError({error}: {error?: string}) { + const t = useTheme() + + if (!error) return null + + return ( + <View + style={[ + {backgroundColor: t.palette.negative_400}, + a.flex_row, + a.rounded_sm, + a.p_md, + a.gap_sm, + ]}> + <Warning fill={t.palette.white} size="md" /> + <View style={[a.flex_1]}> + <Text style={[{color: t.palette.white}, a.font_bold, a.leading_snug]}> + {error} + </Text> + </View> + </View> + ) +} diff --git a/src/components/forms/HostingProvider.tsx b/src/components/forms/HostingProvider.tsx new file mode 100644 index 000000000..f2d11062a --- /dev/null +++ b/src/components/forms/HostingProvider.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import {Keyboard, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {toNiceDomain} from '#/lib/strings/url-helpers' +import {isAndroid} from '#/platform/detection' +import {ServerInputDialog} from '#/view/com/auth/server-input' +import {atoms as a, useTheme} from '#/alf' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' +import {Button} from '../Button' +import {useDialogControl} from '../Dialog' +import {Text} from '../Typography' + +export function HostingProvider({ + serviceUrl, + onSelectServiceUrl, + onOpenDialog, +}: { + serviceUrl: string + onSelectServiceUrl: (provider: string) => void + onOpenDialog?: () => void +}) { + const serverInputControl = useDialogControl() + const t = useTheme() + const {_} = useLingui() + + const onPressSelectService = React.useCallback(() => { + Keyboard.dismiss() + serverInputControl.open() + if (onOpenDialog) { + onOpenDialog() + } + }, [onOpenDialog, serverInputControl]) + + return ( + <> + <ServerInputDialog + control={serverInputControl} + onSelect={onSelectServiceUrl} + /> + <Button + label={toNiceDomain(serviceUrl)} + accessibilityHint={_(msg`Press to change hosting provider`)} + variant="solid" + color="secondary" + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.rounded_sm, + a.px_md, + a.pr_sm, + a.gap_xs, + {paddingVertical: isAndroid ? 14 : 9}, + ]} + onPress={onPressSelectService}> + {({hovered, pressed}) => { + const interacted = hovered || pressed + return ( + <> + <View style={a.pr_xs}> + <Globe + size="md" + fill={ + interacted ? t.palette.contrast_800 : t.palette.contrast_500 + } + /> + </View> + <Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text> + <View + style={[ + a.rounded_sm, + interacted + ? t.atoms.bg_contrast_300 + : t.atoms.bg_contrast_100, + {marginLeft: 'auto', padding: 6}, + ]}> + <Pencil + size="sm" + style={{ + color: interacted + ? t.palette.contrast_800 + : t.palette.contrast_500, + }} + /> + </View> + </> + ) + }} + </Button> + </> + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index ebf2e4750..0bdeca645 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -1,19 +1,20 @@ import React from 'react' import { - View, + AccessibilityProps, + StyleSheet, TextInput, TextInputProps, TextStyle, + View, ViewStyle, - StyleSheet, - AccessibilityProps, } from 'react-native' +import {mergeRefs} from '#/lib/merge-refs' import {HITSLOP_20} from 'lib/constants' -import {useTheme, atoms as a, web, tokens, android} from '#/alf' -import {Text} from '#/components/Typography' +import {android, atoms as a, useTheme, web} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Props as SVGIconProps} from '#/components/icons/common' +import {Text} from '#/components/Typography' const Context = React.createContext<{ inputRef: React.RefObject<TextInput> | null @@ -72,7 +73,7 @@ export function Root({children, isInvalid = false}: RootProps) { return ( <Context.Provider value={context}> <View - style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]} + style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]} {...web({ onClick: () => inputRef.current?.focus(), onMouseOver: onHoverIn, @@ -110,7 +111,7 @@ export function useSharedInputStyles() { { backgroundColor: t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, - borderColor: tokens.color.red_500, + borderColor: t.palette.negative_500, }, ] @@ -125,9 +126,10 @@ export function useSharedInputStyles() { export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { label: string - value: string - onChangeText: (value: string) => void + value?: string + onChangeText?: (value: string) => void isInvalid?: boolean + inputRef?: React.RefObject<TextInput> } export function createInput(Component: typeof TextInput) { @@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) { value, onChangeText, isInvalid, + inputRef, ...rest }: InputProps) { const t = useTheme() @@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) { ) } + const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean)) + return ( <> <Component accessibilityHint={undefined} {...rest} accessibilityLabel={label} - ref={ctx.inputRef} + ref={refs} value={value} onChangeText={onChangeText} onFocus={ctx.onFocus} onBlur={ctx.onBlur} placeholder={placeholder || label} placeholderTextColor={t.palette.contrast_500} + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} hitSlop={HITSLOP_20} style={[ a.relative, @@ -271,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) { <Comp size="md" style={[ - {color: t.palette.contrast_500, pointerEvents: 'none'}, + {color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0}, ctx.hovered ? hover : {}, ctx.focused ? focus : {}, ctx.isInvalid && ctx.hovered ? errorHover : {}, diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 9369423f2..7a4b5ac95 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -2,9 +2,17 @@ import React from 'react' import {Pressable, View, ViewStyle} from 'react-native' import {HITSLOP_10} from 'lib/constants' -import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' +import { + useTheme, + atoms as a, + native, + flatten, + ViewStyleProp, + TextStyleProp, +} from '#/alf' import {Text} from '#/components/Typography' import {useInteractionState} from '#/components/hooks/useInteractionState' +import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' export type ItemState = { name: string @@ -219,20 +227,17 @@ export function Item({ onPressOut={onPressOut} onFocus={onFocus} onBlur={onBlur} - style={[ - a.flex_row, - a.align_center, - a.gap_sm, - focused ? web({outline: 'none'}) : {}, - flatten(style), - ]}> + style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}> {typeof children === 'function' ? children(state) : children} </Pressable> </ItemContext.Provider> ) } -export function Label({children}: React.PropsWithChildren<{}>) { +export function Label({ + children, + style, +}: React.PropsWithChildren<TextStyleProp>) { const t = useTheme() const {disabled} = useItemContext() return ( @@ -241,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) { a.font_bold, { userSelect: 'none', - color: disabled ? t.palette.contrast_400 : t.palette.contrast_600, + color: disabled + ? t.atoms.text_contrast_low.color + : t.atoms.text_contrast_high.color, }, native({ paddingTop: 3, }), + flatten(style), ]}> {children} </Text> @@ -256,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) { export function createSharedToggleStyles({ theme: t, hovered, - focused, selected, disabled, isInvalid, @@ -279,7 +286,7 @@ export function createSharedToggleStyles({ borderColor: t.palette.primary_500, }) - if (hovered || focused) { + if (hovered) { baseHover.push({ backgroundColor: t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800, @@ -288,7 +295,7 @@ export function createSharedToggleStyles({ }) } } else { - if (hovered || focused) { + if (hovered) { baseHover.push({ backgroundColor: t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100, @@ -300,16 +307,16 @@ export function createSharedToggleStyles({ if (isInvalid) { base.push({ backgroundColor: - t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975, borderColor: t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800, }) - if (hovered || focused) { + if (hovered) { baseHover.push({ backgroundColor: t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, - borderColor: t.palette.negative_500, + borderColor: t.palette.negative_600, }) } } @@ -331,15 +338,14 @@ export function createSharedToggleStyles({ export function Checkbox() { const t = useTheme() const {selected, hovered, focused, disabled, isInvalid} = useItemContext() - const {baseStyles, baseHoverStyles, indicatorStyles} = - createSharedToggleStyles({ - theme: t, - hovered, - focused, - selected, - disabled, - isInvalid, - }) + const {baseStyles, baseHoverStyles} = createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) return ( <View style={[ @@ -353,23 +359,9 @@ export function Checkbox() { width: 20, }, baseStyles, - hovered || focused ? baseHoverStyles : {}, + hovered ? baseHoverStyles : {}, ]}> - {selected ? ( - <View - style={[ - a.absolute, - a.rounded_2xs, - {height: 12, width: 12}, - selected - ? { - backgroundColor: t.palette.primary_500, - } - : {}, - indicatorStyles, - ]} - /> - ) : null} + {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null} </View> ) } @@ -399,7 +391,7 @@ export function Switch() { width: 30, }, baseStyles, - hovered || focused ? baseHoverStyles : {}, + hovered ? baseHoverStyles : {}, ]}> <View style={[ @@ -451,7 +443,7 @@ export function Radio() { width: 20, }, baseStyles, - hovered || focused ? baseHoverStyles : {}, + hovered ? baseHoverStyles : {}, ]}> {selected ? ( <View diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 7e1bd70b9..9cdaaaa9d 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -8,7 +8,9 @@ import * as Toggle from '#/components/forms/Toggle' export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> & AccessibilityProps & - React.PropsWithChildren<{testID?: string}> + React.PropsWithChildren<{ + testID?: string + }> export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & { multiple?: boolean @@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) { native({ paddingBottom: 10, }), - a.px_sm, + a.px_md, t.atoms.bg, t.atoms.border_contrast_low, baseStyles, activeStyles, - (state.hovered || state.focused || state.pressed) && hoverStyles, + (state.hovered || state.pressed) && hoverStyles, ]}> {typeof children === 'string' ? ( <Text diff --git a/src/components/hooks/useDelayedLoading.ts b/src/components/hooks/useDelayedLoading.ts new file mode 100644 index 000000000..6c7e2ede0 --- /dev/null +++ b/src/components/hooks/useDelayedLoading.ts @@ -0,0 +1,15 @@ +import React from 'react' + +export function useDelayedLoading(delay: number, initialState: boolean = true) { + const [isLoading, setIsLoading] = React.useState(initialState) + + React.useEffect(() => { + let timeout: NodeJS.Timeout + // on initial load, show a loading spinner for a hot sec to prevent flash + if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay) + + return () => timeout && clearTimeout(timeout) + }, [isLoading, delay]) + + return isLoading +} diff --git a/src/components/hooks/useOnKeyboard.ts b/src/components/hooks/useOnKeyboard.ts new file mode 100644 index 000000000..5de681a42 --- /dev/null +++ b/src/components/hooks/useOnKeyboard.ts @@ -0,0 +1,12 @@ +import React from 'react' +import {Keyboard} from 'react-native' + +export function useOnKeyboardDidShow(cb: () => unknown) { + React.useEffect(() => { + const subscription = Keyboard.addListener('keyboardDidShow', cb) + + return () => { + subscription.remove() + } + }, [cb]) +} diff --git a/src/components/icons/ArrowTriangle.tsx b/src/components/icons/ArrowTriangle.tsx new file mode 100644 index 000000000..b27b719ae --- /dev/null +++ b/src/components/icons/ArrowTriangle.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({ + path: 'M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z', +}) diff --git a/src/components/icons/Bars.tsx b/src/components/icons/Bars.tsx new file mode 100644 index 000000000..7b1415a4b --- /dev/null +++ b/src/components/icons/Bars.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/Bubble.tsx b/src/components/icons/Bubble.tsx new file mode 100644 index 000000000..d4e08f6d2 --- /dev/null +++ b/src/components/icons/Bubble.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z', +}) diff --git a/src/components/icons/Calendar.tsx b/src/components/icons/Calendar.tsx new file mode 100644 index 000000000..b3816f28b --- /dev/null +++ b/src/components/icons/Calendar.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z', +}) diff --git a/src/components/icons/Camera.tsx b/src/components/icons/Camera.tsx new file mode 100644 index 000000000..ced8e7442 --- /dev/null +++ b/src/components/icons/Camera.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Camera_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z', +}) + +export const Camera_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z', +}) diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx index 24316c784..fe9883baf 100644 --- a/src/components/icons/Check.tsx +++ b/src/components/icons/Check.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z', }) + +export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z', +}) diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx index b1a9deea0..a04e6e009 100644 --- a/src/components/icons/Chevron.tsx +++ b/src/components/icons/Chevron.tsx @@ -7,3 +7,11 @@ export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z', }) + +export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z', +}) + +export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/CircleBanSign.tsx b/src/components/icons/CircleBanSign.tsx new file mode 100644 index 000000000..543985d43 --- /dev/null +++ b/src/components/icons/CircleBanSign.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z', +}) diff --git a/src/components/icons/Clipboard.tsx b/src/components/icons/Clipboard.tsx new file mode 100644 index 000000000..0135992b4 --- /dev/null +++ b/src/components/icons/Clipboard.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z', +}) diff --git a/src/components/icons/DotGrid.tsx b/src/components/icons/DotGrid.tsx new file mode 100644 index 000000000..c50d7a440 --- /dev/null +++ b/src/components/icons/DotGrid.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', +}) diff --git a/src/components/icons/Envelope.tsx b/src/components/icons/Envelope.tsx new file mode 100644 index 000000000..8e40346cd --- /dev/null +++ b/src/components/icons/Envelope.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z', +}) diff --git a/src/components/icons/Filter.tsx b/src/components/icons/Filter.tsx new file mode 100644 index 000000000..02ac1c71b --- /dev/null +++ b/src/components/icons/Filter.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Filter_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z', +}) diff --git a/src/components/icons/Flag.tsx b/src/components/icons/Flag.tsx new file mode 100644 index 000000000..d986db75a --- /dev/null +++ b/src/components/icons/Flag.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Flag_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 4a2 2 0 0 1 2-2h13.131c1.598 0 2.55 1.78 1.665 3.11L18.202 9l2.594 3.89c.886 1.33-.067 3.11-1.665 3.11H6v5a1 1 0 1 1-2 0V4Zm2 10h13.131l-2.593-3.89a2 2 0 0 1 0-2.22L19.13 4H6v10Z', +}) diff --git a/src/components/icons/Gear.tsx b/src/components/icons/Gear.tsx new file mode 100644 index 000000000..980b7413b --- /dev/null +++ b/src/components/icons/Gear.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', +}) diff --git a/src/components/icons/Group.tsx b/src/components/icons/Group.tsx new file mode 100644 index 000000000..9e5ab8893 --- /dev/null +++ b/src/components/icons/Group.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z', +}) diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group3.tsx deleted file mode 100644 index 2bb16ba87..000000000 --- a/src/components/icons/Group3.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {createSinglePathSVG} from './TEMPLATE' - -export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({ - path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z', -}) diff --git a/src/components/icons/Heart2.tsx b/src/components/icons/Heart2.tsx new file mode 100644 index 000000000..07f5a1d2c --- /dev/null +++ b/src/components/icons/Heart2.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Heart2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z', +}) + +export const Heart2_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z', +}) diff --git a/src/components/icons/Lock.tsx b/src/components/icons/Lock.tsx new file mode 100644 index 000000000..87830b379 --- /dev/null +++ b/src/components/icons/Lock.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/icons/MagnifyingGlass2.tsx b/src/components/icons/MagnifyingGlass2.tsx new file mode 100644 index 000000000..3ca403400 --- /dev/null +++ b/src/components/icons/MagnifyingGlass2.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z', +}) diff --git a/src/components/icons/Mute.tsx b/src/components/icons/Mute.tsx new file mode 100644 index 000000000..006570787 --- /dev/null +++ b/src/components/icons/Mute.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z', +}) diff --git a/src/components/icons/PageText.tsx b/src/components/icons/PageText.tsx new file mode 100644 index 000000000..25fbde339 --- /dev/null +++ b/src/components/icons/PageText.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z', +}) diff --git a/src/components/icons/Pencil.tsx b/src/components/icons/Pencil.tsx new file mode 100644 index 000000000..854d51a3b --- /dev/null +++ b/src/components/icons/Pencil.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PencilLine_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/PeopleRemove2.tsx b/src/components/icons/PeopleRemove2.tsx new file mode 100644 index 000000000..3d16ed968 --- /dev/null +++ b/src/components/icons/PeopleRemove2.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PeopleRemove2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z', +}) diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx new file mode 100644 index 000000000..6d09148c9 --- /dev/null +++ b/src/components/icons/Person.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z', +}) diff --git a/src/components/icons/PersonCheck.tsx b/src/components/icons/PersonCheck.tsx new file mode 100644 index 000000000..097271d89 --- /dev/null +++ b/src/components/icons/PersonCheck.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z', +}) diff --git a/src/components/icons/PersonX.tsx b/src/components/icons/PersonX.tsx new file mode 100644 index 000000000..a015e1376 --- /dev/null +++ b/src/components/icons/PersonX.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/RaisingHand.tsx b/src/components/icons/RaisingHand.tsx new file mode 100644 index 000000000..cd023cb7e --- /dev/null +++ b/src/components/icons/RaisingHand.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z', +}) diff --git a/src/components/icons/Shield.tsx b/src/components/icons/Shield.tsx new file mode 100644 index 000000000..5038d5c24 --- /dev/null +++ b/src/components/icons/Shield.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z', +}) diff --git a/src/components/icons/Speaker.tsx b/src/components/icons/Speaker.tsx new file mode 100644 index 000000000..365d5e114 --- /dev/null +++ b/src/components/icons/Speaker.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const SpeakerVolumeFull_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/SquareArrowTopRight.tsx b/src/components/icons/SquareArrowTopRight.tsx new file mode 100644 index 000000000..7701e26e5 --- /dev/null +++ b/src/components/icons/SquareArrowTopRight.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const SquareArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z', +}) diff --git a/src/components/icons/SquareBehindSquare4.tsx b/src/components/icons/SquareBehindSquare4.tsx new file mode 100644 index 000000000..425599cbc --- /dev/null +++ b/src/components/icons/SquareBehindSquare4.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z', +}) diff --git a/src/components/icons/StreamingLive.tsx b/src/components/icons/StreamingLive.tsx new file mode 100644 index 000000000..8ab5099da --- /dev/null +++ b/src/components/icons/StreamingLive.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const StreamingLive_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z', +}) diff --git a/src/components/icons/Ticket.tsx b/src/components/icons/Ticket.tsx new file mode 100644 index 000000000..1a8059c2a --- /dev/null +++ b/src/components/icons/Ticket.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z', +}) diff --git a/src/components/icons/Times.tsx b/src/components/icons/Times.tsx new file mode 100644 index 000000000..678ac3fcb --- /dev/null +++ b/src/components/icons/Times.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const TimesLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/Trash.tsx b/src/components/icons/Trash.tsx new file mode 100644 index 000000000..d09a3311f --- /dev/null +++ b/src/components/icons/Trash.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Trash_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/icons/Warning.tsx b/src/components/icons/Warning.tsx new file mode 100644 index 000000000..fc84b2894 --- /dev/null +++ b/src/components/icons/Warning.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Warning_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z', +}) diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx new file mode 100644 index 000000000..1e8f36d31 --- /dev/null +++ b/src/components/moderation/ContentHider.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {isJustAMute} from '#/lib/moderation' +import {sanitizeDisplayName} from '#/lib/strings/display-names' + +import {atoms as a, useTheme, useBreakpoints, web} from '#/alf' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ContentHider({ + testID, + modui, + ignoreMute, + style, + childContainerStyle, + children, +}: React.PropsWithChildren<{ + testID?: string + modui: ModerationUI | undefined + ignoreMute?: boolean + style?: StyleProp<ViewStyle> + childContainerStyle?: StyleProp<ViewStyle> +}>) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const [override, setOverride] = React.useState(false) + const control = useModerationDetailsDialogControl() + + const blur = modui?.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur || (ignoreMute && isJustAMute(modui))) { + return ( + <View testID={testID} style={[styles.outer, style]}> + {children} + </View> + ) + } + + return ( + <View testID={testID} style={[a.overflow_hidden, style]}> + <ModerationDetailsDialog control={control} modcause={blur} /> + + <Button + onPress={() => { + if (!modui.noOverride) { + setOverride(v => !v) + } else { + control.open() + } + }} + label={desc.name} + accessibilityHint={ + modui.noOverride + ? _(msg`Learn more about the moderation applied to this content.`) + : override + ? _(msg`Hide the content`) + : _(msg`Show the content`) + }> + {state => ( + <View + style={[ + a.flex_row, + a.w_full, + a.justify_start, + a.align_center, + a.py_md, + a.px_lg, + a.gap_xs, + a.rounded_sm, + t.atoms.bg_contrast_25, + gtMobile && [a.gap_sm, a.py_lg, a.mt_xs, a.px_xl], + (state.hovered || state.pressed) && t.atoms.bg_contrast_50, + ]}> + <desc.icon + size="md" + fill={t.atoms.text_contrast_medium.color} + style={{marginLeft: -2}} + /> + <Text + style={[ + a.flex_1, + a.text_left, + a.font_bold, + a.leading_snug, + gtMobile && [a.font_semibold], + t.atoms.text_contrast_medium, + web({ + marginBottom: 1, + }), + ]}> + {desc.name} + </Text> + {!modui.noOverride && ( + <Text + style={[ + a.font_bold, + a.leading_snug, + gtMobile && [a.font_semibold], + t.atoms.text_contrast_high, + web({ + marginBottom: 1, + }), + ]}> + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} + </Text> + )} + </View> + )} + </Button> + + {desc.source && blur.type === 'label' && !override && ( + <Button + onPress={() => { + control.open() + }} + label={_( + msg`Learn more about the moderation applied to this content.`, + )} + style={[a.pt_sm]}> + {state => ( + <Text + style={[ + a.flex_1, + a.text_sm, + a.font_normal, + a.leading_snug, + t.atoms.text_contrast_medium, + a.text_left, + ]}> + {desc.sourceType === 'user' ? ( + <Trans>Labeled by the author.</Trans> + ) : ( + <Trans>Labeled by {sanitizeDisplayName(desc.source!)}.</Trans> + )}{' '} + <Text + style={[ + {color: t.palette.primary_500}, + a.text_sm, + state.hovered && [web({textDecoration: 'underline'})], + ]}> + <Trans>Learn more.</Trans> + </Text> + </Text> + )} + </Button> + )} + + {override && <View style={childContainerStyle}>{children}</View>} + </View> + ) +} + +const styles = StyleSheet.create({ + outer: { + overflow: 'hidden', + }, + cover: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderRadius: 8, + marginTop: 4, + paddingVertical: 14, + paddingLeft: 14, + paddingRight: 18, + }, + showBtn: { + marginLeft: 'auto', + alignSelf: 'center', + }, +}) diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx new file mode 100644 index 000000000..028bd1a39 --- /dev/null +++ b/src/components/moderation/LabelPreference.tsx @@ -0,0 +1,293 @@ +import React from 'react' +import {View} from 'react-native' +import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' +import {getLabelStrings} from '#/lib/moderation/useLabelInfo' +import { + usePreferencesQuery, + usePreferencesSetContentLabelMutation, +} from '#/state/queries/preferences' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import * as ToggleButton from '#/components/forms/ToggleButton' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' + +export function Outer({children}: React.PropsWithChildren<{}>) { + return ( + <View + style={[ + a.flex_row, + a.gap_md, + a.px_lg, + a.py_lg, + a.justify_between, + a.flex_wrap, + ]}> + {children} + </View> + ) +} + +export function Content({ + children, + name, + description, +}: React.PropsWithChildren<{ + name: string + description: string +}>) { + const t = useTheme() + const {gtPhone} = useBreakpoints() + + return ( + <View style={[a.gap_xs, a.flex_1]}> + <Text style={[a.font_bold, gtPhone ? a.text_sm : a.text_md]}>{name}</Text> + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> + {description} + </Text> + + {children} + </View> + ) +} + +export function Buttons({ + name, + values, + onChange, + ignoreLabel, + warnLabel, + hideLabel, +}: { + name: string + values: ToggleButton.GroupProps['values'] + onChange: ToggleButton.GroupProps['onChange'] + ignoreLabel?: string + warnLabel?: string + hideLabel?: string +}) { + const {_} = useLingui() + const {gtPhone} = useBreakpoints() + + return ( + <View style={[{minHeight: 35}, gtPhone ? undefined : a.w_full]}> + <ToggleButton.Group + label={_( + msg`Configure content filtering setting for category: ${name}`, + )} + values={values} + onChange={onChange}> + {ignoreLabel && ( + <ToggleButton.Button name="ignore" label={ignoreLabel}> + {ignoreLabel} + </ToggleButton.Button> + )} + {warnLabel && ( + <ToggleButton.Button name="warn" label={warnLabel}> + {warnLabel} + </ToggleButton.Button> + )} + {hideLabel && ( + <ToggleButton.Button name="hide" label={hideLabel}> + {hideLabel} + </ToggleButton.Button> + )} + </ToggleButton.Group> + </View> + ) +} + +/** + * For use on the global Moderation screen to set prefs for a "global" label, + * not scoped to a single labeler. + */ +export function GlobalLabelPreference({ + labelDefinition, + disabled, +}: { + labelDefinition: InterpretedLabelValueDefinition + disabled?: boolean +}) { + const {_} = useLingui() + + const {identifier} = labelDefinition + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetContentLabelMutation() + const savedPref = preferences?.moderationPrefs.labels[identifier] + const pref = variables?.visibility ?? savedPref ?? 'warn' + + const allLabelStrings = useGlobalLabelStrings() + const labelStrings = + labelDefinition.identifier in allLabelStrings + ? allLabelStrings[labelDefinition.identifier] + : { + name: labelDefinition.identifier, + description: `Labeled "${labelDefinition.identifier}"`, + } + + const labelOptions = { + hide: _(msg`Hide`), + warn: _(msg`Warn`), + ignore: _(msg`Show`), + } + + return ( + <Outer> + <Content + name={labelStrings.name} + description={labelStrings.description} + /> + {!disabled && ( + <Buttons + name={labelStrings.name.toLowerCase()} + values={[pref]} + onChange={values => { + mutate({ + label: identifier, + visibility: values[0] as LabelPreference, + labelerDid: undefined, + }) + }} + ignoreLabel={labelOptions.ignore} + warnLabel={labelOptions.warn} + hideLabel={labelOptions.hide} + /> + )} + </Outer> + ) +} + +/** + * For use on individual labeler pages + */ +export function LabelerLabelPreference({ + labelDefinition, + disabled, + labelerDid, +}: { + labelDefinition: InterpretedLabelValueDefinition + disabled?: boolean + labelerDid?: string +}) { + const {i18n} = useLingui() + const t = useTheme() + const {gtPhone} = useBreakpoints() + + const isGlobalLabel = !labelDefinition.definedBy + const {identifier} = labelDefinition + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetContentLabelMutation() + const savedPref = + labelerDid && !isGlobalLabel + ? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid) + ?.labels[identifier] + : preferences?.moderationPrefs.labels[identifier] + const pref = + variables?.visibility ?? + savedPref ?? + labelDefinition.defaultSetting ?? + 'warn' + + // does the 'warn' setting make sense for this label? + const canWarn = !( + labelDefinition.blurs === 'none' && labelDefinition.severity === 'none' + ) + // is this label adult only? + const adultOnly = labelDefinition.flags.includes('adult') + // is this label disabled because it's adult only? + const adultDisabled = + adultOnly && !preferences?.moderationPrefs.adultContentEnabled + // are there any reasons we cant configure this label here? + const cantConfigure = isGlobalLabel || adultDisabled + const showConfig = !disabled && (gtPhone || !cantConfigure) + + // adjust the pref based on whether warn is available + let prefAdjusted = pref + if (adultDisabled) { + prefAdjusted = 'hide' + } else if (!canWarn && pref === 'warn') { + prefAdjusted = 'ignore' + } + + // grab localized descriptions of the label and its settings + const currentPrefLabel = useLabelBehaviorDescription( + labelDefinition, + prefAdjusted, + ) + const hideLabel = useLabelBehaviorDescription(labelDefinition, 'hide') + const warnLabel = useLabelBehaviorDescription(labelDefinition, 'warn') + const ignoreLabel = useLabelBehaviorDescription(labelDefinition, 'ignore') + const globalLabelStrings = useGlobalLabelStrings() + const labelStrings = getLabelStrings( + i18n.locale, + globalLabelStrings, + labelDefinition, + ) + + return ( + <Outer> + <Content name={labelStrings.name} description={labelStrings.description}> + {cantConfigure && ( + <View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}> + <CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} /> + + <Text + style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}> + {adultDisabled ? ( + <Trans>Adult content is disabled.</Trans> + ) : isGlobalLabel ? ( + <Trans> + Configured in{' '} + <InlineLink to="/moderation" style={a.text_sm}> + moderation settings + </InlineLink> + . + </Trans> + ) : null} + </Text> + </View> + )} + </Content> + + {showConfig && ( + <View style={[gtPhone ? undefined : a.w_full]}> + {cantConfigure ? ( + <View + style={[ + {minHeight: 35}, + a.px_md, + a.py_md, + a.rounded_sm, + a.border, + t.atoms.border_contrast_low, + ]}> + <Text style={[a.font_bold, t.atoms.text_contrast_low]}> + {currentPrefLabel} + </Text> + </View> + ) : ( + <Buttons + name={labelStrings.name.toLowerCase()} + values={[pref]} + onChange={values => { + mutate({ + label: identifier, + visibility: values[0] as LabelPreference, + labelerDid, + }) + }} + ignoreLabel={ignoreLabel} + warnLabel={canWarn ? warnLabel : undefined} + hideLabel={hideLabel} + /> + )} + </View> + )} + </Outer> + ) +} diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx new file mode 100644 index 000000000..099769fa7 --- /dev/null +++ b/src/components/moderation/LabelsOnMe.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import { + LabelsOnMeDialog, + useLabelsOnMeDialogControl, +} from '#/components/moderation/LabelsOnMeDialog' + +export function LabelsOnMe({ + details, + labels, + size, + style, +}: { + details: {did: string} | {uri: string; cid: string} + labels: ComAtprotoLabelDefs.Label[] | undefined + size?: ButtonSize + style?: StyleProp<ViewStyle> +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const isAccount = 'did' in details + const control = useLabelsOnMeDialogControl() + + if (!labels || !currentAccount) { + return null + } + labels = labels.filter( + l => !l.val.startsWith('!') && l.src !== currentAccount.did, + ) + if (!labels.length) { + return null + } + + const labelTarget = isAccount ? _(msg`account`) : _(msg`content`) + return ( + <View style={[a.flex_row, style]}> + <LabelsOnMeDialog control={control} subject={details} labels={labels} /> + + <Button + variant="solid" + color="secondary" + size={size || 'small'} + label={_(msg`View information about these labels`)} + onPress={() => { + control.open() + }}> + <ButtonIcon position="left" icon={CircleInfo} /> + <ButtonText style={[a.leading_snug]}> + {labels.length}{' '} + {labels.length === 1 ? ( + <Trans>label has been placed on this {labelTarget}</Trans> + ) : ( + <Trans>labels have been placed on this {labelTarget}</Trans> + )} + </ButtonText> + </Button> + </View> + ) +} + +export function LabelsOnMyPost({ + post, + style, +}: { + post: AppBskyFeedDefs.PostView + style?: StyleProp<ViewStyle> +}) { + const {currentAccount} = useSession() + if (post.author.did !== currentAccount?.did) { + return null + } + return ( + <LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} /> + ) +} diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx new file mode 100644 index 000000000..6eddbc7ce --- /dev/null +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -0,0 +1,262 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api' + +import {useLabelInfo} from '#/lib/moderation/useLabelInfo' +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeHandle} from '#/lib/strings/handles' +import {getAgent} from '#/state/session' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {Button, ButtonText} from '#/components/Button' +import {InlineLink} from '#/components/Link' +import * as Toast from '#/view/com/util/Toast' +import {Divider} from '../Divider' + +export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog' + +type Subject = + | { + uri: string + cid: string + } + | { + did: string + } + +export interface LabelsOnMeDialogProps { + control: Dialog.DialogOuterProps['control'] + subject: Subject + labels: ComAtprotoLabelDefs.Label[] +} + +export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { + const {_} = useLingui() + const [appealingLabel, setAppealingLabel] = React.useState< + ComAtprotoLabelDefs.Label | undefined + >(undefined) + const {subject, labels} = props + const isAccount = 'did' in subject + + return ( + <Dialog.ScrollableInner + label={ + isAccount + ? _(msg`The following labels were applied to your account.`) + : _(msg`The following labels were applied to your content.`) + }> + {appealingLabel ? ( + <AppealForm + label={appealingLabel} + subject={subject} + control={props.control} + onPressBack={() => setAppealingLabel(undefined)} + /> + ) : ( + <> + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> + {isAccount ? ( + <Trans>Labels on your account</Trans> + ) : ( + <Trans>Labels on your content</Trans> + )} + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + You may appeal these labels if you feel they were placed in error. + </Trans> + </Text> + + <View style={[a.py_lg, a.gap_md]}> + {labels.map(label => ( + <Label + key={`${label.val}-${label.src}`} + label={label} + control={props.control} + onPressAppeal={label => setAppealingLabel(label)} + /> + ))} + </View> + </> + )} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + + <LabelsOnMeDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function Label({ + label, + control, + onPressAppeal, +}: { + label: ComAtprotoLabelDefs.Label + control: Dialog.DialogOuterProps['control'] + onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void +}) { + const t = useTheme() + const {_} = useLingui() + const {labeler, strings} = useLabelInfo(label) + return ( + <View + style={[ + a.border, + t.atoms.border_contrast_low, + a.rounded_sm, + a.overflow_hidden, + ]}> + <View style={[a.p_md, a.gap_sm, a.flex_row]}> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.font_bold, a.text_md]}>{strings.name}</Text> + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> + {strings.description} + </Text> + </View> + <View> + <Button + variant="solid" + color="secondary" + size="small" + label={_(msg`Appeal`)} + onPress={() => onPressAppeal(label)}> + <ButtonText> + <Trans>Appeal</Trans> + </ButtonText> + </Button> + </View> + </View> + + <Divider /> + + <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Source:</Trans>{' '} + <InlineLink + to={makeProfileLink( + labeler ? labeler.creator : {did: label.src, handle: ''}, + )} + onPress={() => control.close()}> + {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} + </InlineLink> + </Text> + </View> + </View> + ) +} + +function AppealForm({ + label, + subject, + control, + onPressBack, +}: { + label: ComAtprotoLabelDefs.Label + subject: Subject + control: Dialog.DialogOuterProps['control'] + onPressBack: () => void +}) { + const {_} = useLingui() + const {labeler, strings} = useLabelInfo(label) + const {gtMobile} = useBreakpoints() + const [details, setDetails] = React.useState('') + const isAccountReport = 'did' in subject + + const onSubmit = async () => { + try { + const $type = !isAccountReport + ? 'com.atproto.repo.strongRef' + : 'com.atproto.admin.defs#repoRef' + await getAgent() + .withProxy('atproto_labeler', label.src) + .createModerationReport({ + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, + subject: { + $type, + ...subject, + }, + reason: details, + }) + Toast.show(_(msg`Appeal submitted.`)) + } finally { + control.close() + } + } + + return ( + <> + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> + <Trans>Appeal "{strings.name}" label</Trans> + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + This appeal will be sent to{' '} + <InlineLink + to={makeProfileLink( + labeler ? labeler.creator : {did: label.src, handle: ''}, + )} + onPress={() => control.close()} + style={[a.text_md, a.leading_snug]}> + {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} + </InlineLink> + . + </Trans> + </Text> + <View style={[a.my_md]}> + <Dialog.Input + label={_(msg`Text input field`)} + placeholder={_( + msg`Please explain why you think this label was incorrectly applied by ${ + labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src + }`, + )} + value={details} + onChangeText={setDetails} + autoFocus={true} + numberOfLines={3} + multiline + maxLength={300} + /> + </View> + + <View + style={ + gtMobile + ? [a.flex_row, a.justify_between] + : [{flexDirection: 'column-reverse'}, a.gap_sm] + }> + <Button + testID="backBtn" + variant="solid" + color="secondary" + size="medium" + onPress={onPressBack} + label={_(msg`Back`)}> + {_(msg`Back`)} + </Button> + <Button + testID="submitBtn" + variant="solid" + color="primary" + size="medium" + onPress={onSubmit} + label={_(msg`Submit`)}> + {_(msg`Submit`)} + </Button> + </View> + </> + ) +} diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx new file mode 100644 index 000000000..da490cb43 --- /dev/null +++ b/src/components/moderation/ModerationDetailsDialog.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {ModerationCause} from '@atproto/api' + +import {listUriToHref} from '#/lib/strings/url-helpers' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {makeProfileLink} from '#/lib/routes/links' + +import {isNative} from '#/platform/detection' +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {InlineLink} from '#/components/Link' +import {Divider} from '#/components/Divider' + +export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog' + +export interface ModerationDetailsDialogProps { + control: Dialog.DialogOuterProps['control'] + modcause: ModerationCause +} + +export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + <ModerationDetailsDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function ModerationDetailsDialogInner({ + modcause, + control, +}: ModerationDetailsDialogProps & { + control: Dialog.DialogOuterProps['control'] +}) { + const t = useTheme() + const {_} = useLingui() + const desc = useModerationCauseDescription(modcause) + + let name + let description + if (!modcause) { + name = _(msg`Content Warning`) + description = _( + msg`Moderator has chosen to set a general warning on the content.`, + ) + } else if (modcause.type === 'blocking') { + if (modcause.source.type === 'list') { + const list = modcause.source.list + name = _(msg`User Blocked by List`) + description = ( + <Trans> + This user is included in the{' '} + <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}> + {list.name} + </InlineLink>{' '} + list which you have blocked. + </Trans> + ) + } else { + name = _(msg`User Blocked`) + description = _( + msg`You have blocked this user. You cannot view their content.`, + ) + } + } else if (modcause.type === 'blocked-by') { + name = _(msg`User Blocks You`) + description = _( + msg`This user has blocked you. You cannot view their content.`, + ) + } else if (modcause.type === 'block-other') { + name = _(msg`Content Not Available`) + description = _( + msg`This content is not available because one of the users involved has blocked the other.`, + ) + } else if (modcause.type === 'muted') { + if (modcause.source.type === 'list') { + const list = modcause.source.list + name = _(msg`Account Muted by List`) + description = ( + <Trans> + This user is included in the{' '} + <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}> + {list.name} + </InlineLink>{' '} + list which you have muted. + </Trans> + ) + } else { + name = _(msg`Account Muted`) + description = _(msg`You have muted this account.`) + } + } else if (modcause.type === 'mute-word') { + name = _(msg`Post Hidden by Muted Word`) + description = _(msg`You've chosen to hide a word or tag within this post.`) + } else if (modcause.type === 'hidden') { + name = _(msg`Post Hidden by You`) + description = _(msg`You have hidden this post.`) + } else if (modcause.type === 'label') { + name = desc.name + description = desc.description + } else { + // should never happen + name = '' + description = '' + } + + return ( + <Dialog.ScrollableInner label={_(msg`Moderation details`)}> + <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> + {name} + </Text> + <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}> + {description} + </Text> + + {modcause.type === 'label' && ( + <> + <Divider /> + <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> + <Trans> + This label was applied by{' '} + {modcause.source.type === 'user' ? ( + <Trans>the author</Trans> + ) : ( + <InlineLink + to={makeProfileLink({did: modcause.label.src, handle: ''})} + onPress={() => control.close()} + style={a.text_md}> + {desc.source} + </InlineLink> + )} + . + </Trans> + </Text> + </> + )} + + {isNative && <View style={{height: 40}} />} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx new file mode 100644 index 000000000..0bfe69678 --- /dev/null +++ b/src/components/moderation/PostAlerts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {ModerationUI, ModerationCause} from '@atproto/api' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {getModerationCauseKey} from '#/lib/moderation' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function PostAlerts({ + modui, + style, +}: { + modui: ModerationUI + includeMute?: boolean + style?: StyleProp<ViewStyle> +}) { + if (!modui.alert && !modui.inform) { + return null + } + + return ( + <View style={[a.flex_col, a.gap_xs, style]}> + <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> + {modui.alerts.map(cause => ( + <PostLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + {modui.informs.map(cause => ( + <PostLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + </View> + </View> + ) +} + +function PostLabel({cause}: {cause: ModerationCause}) { + const control = useModerationDetailsDialogControl() + const desc = useModerationCauseDescription(cause) + + return ( + <> + <Button + label={desc.name} + variant="solid" + color="secondary" + size="small" + shape="default" + onPress={() => { + control.open() + }} + style={[a.px_sm, a.py_xs, a.gap_xs]}> + <ButtonIcon icon={desc.icon} position="left" /> + <ButtonText style={[a.text_left, a.leading_snug]}> + {desc.name} + </ButtonText> + </Button> + + <ModerationDetailsDialog control={control} modcause={cause} /> + </> + ) +} diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx new file mode 100644 index 000000000..464ee2077 --- /dev/null +++ b/src/components/moderation/PostHider.tsx @@ -0,0 +1,129 @@ +import React, {ComponentProps} from 'react' +import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {addStyle} from 'lib/styles' + +import {useTheme, atoms as a} from '#/alf' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' +import {Text} from '#/components/Typography' +// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up +import {Link} from '#/view/com/util/Link' + +interface Props extends ComponentProps<typeof Link> { + iconSize: number + iconStyles: StyleProp<ViewStyle> + modui: ModerationUI +} + +export function PostHider({ + testID, + href, + modui, + style, + children, + iconSize, + iconStyles, + ...props +}: Props) { + const t = useTheme() + const {_} = useLingui() + const [override, setOverride] = React.useState(false) + const control = useModerationDetailsDialogControl() + const blur = modui.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur) { + return ( + <Link + testID={testID} + style={style} + href={href} + accessible={false} + {...props}> + {children} + </Link> + ) + } + + return !override ? ( + <Pressable + onPress={() => { + if (!modui.noOverride) { + setOverride(v => !v) + } + }} + accessibilityRole="button" + accessibilityHint={ + override ? _(msg`Hide the content`) : _(msg`Show the content`) + } + accessibilityLabel="" + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + a.py_md, + { + paddingLeft: 6, + paddingRight: 18, + }, + override ? {paddingBottom: 0} : undefined, + t.atoms.bg, + ]}> + <ModerationDetailsDialog control={control} modcause={blur} /> + <Pressable + onPress={() => { + control.open() + }} + accessibilityRole="button" + accessibilityLabel={_(msg`Learn more about this warning`)} + accessibilityHint=""> + <View + style={[ + t.atoms.bg_contrast_25, + a.align_center, + a.justify_center, + { + width: iconSize, + height: iconSize, + borderRadius: iconSize, + }, + iconStyles, + ]}> + <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} /> + </View> + </Pressable> + <Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}> + {desc.name} + </Text> + {!modui.noOverride && ( + <Text style={[{color: t.palette.primary_500}]}> + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} + </Text> + )} + </Pressable> + ) : ( + <Link + testID={testID} + style={addStyle(style, styles.child)} + href={href} + accessible={false} + {...props}> + {children} + </Link> + ) +} + +const styles = StyleSheet.create({ + child: { + borderWidth: 0, + borderTopWidth: 0, + borderRadius: 8, + }, +}) diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx new file mode 100644 index 000000000..dfc2aa557 --- /dev/null +++ b/src/components/moderation/ProfileHeaderAlerts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {ModerationCause, ModerationDecision} from '@atproto/api' + +import {getModerationCauseKey} from 'lib/moderation' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ProfileHeaderAlerts({ + moderation, + style, +}: { + moderation: ModerationDecision + style?: StyleProp<ViewStyle> +}) { + const modui = moderation.ui('profileView') + if (!modui.alert && !modui.inform) { + return null + } + + return ( + <View style={[a.flex_col, a.gap_xs, style]}> + <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> + {modui.alerts.map(cause => ( + <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + {modui.informs.map(cause => ( + <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + </View> + </View> + ) +} + +function ProfileLabel({cause}: {cause: ModerationCause}) { + const control = useModerationDetailsDialogControl() + const desc = useModerationCauseDescription(cause) + + return ( + <> + <Button + label={desc.name} + variant="solid" + color="secondary" + size="small" + shape="default" + onPress={() => { + control.open() + }} + style={[a.px_sm, a.py_xs, a.gap_xs]}> + <ButtonIcon icon={desc.icon} position="left" /> + <ButtonText style={[a.text_left, a.leading_snug]}> + {desc.name} + </ButtonText> + </Button> + + <ModerationDetailsDialog control={control} modcause={cause} /> + </> + ) +} diff --git a/src/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx new file mode 100644 index 000000000..4e3a9680f --- /dev/null +++ b/src/components/moderation/ScreenHider.tsx @@ -0,0 +1,172 @@ +import React from 'react' +import { + TouchableWithoutFeedback, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import {ModerationUI} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {NavigationProp} from 'lib/routes/types' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' + +import {useTheme, atoms as a} from '#/alf' +import {CenteredView} from '#/view/com/util/Views' +import {Text} from '#/components/Typography' +import {Button, ButtonText} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ScreenHider({ + testID, + screenDescription, + modui, + style, + containerStyle, + children, +}: React.PropsWithChildren<{ + testID?: string + screenDescription: string + modui: ModerationUI + style?: StyleProp<ViewStyle> + containerStyle?: StyleProp<ViewStyle> +}>) { + const t = useTheme() + const {_} = useLingui() + const [override, setOverride] = React.useState(false) + const navigation = useNavigation<NavigationProp>() + const {isMobile} = useWebMediaQueries() + const control = useModerationDetailsDialogControl() + const blur = modui.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur || override) { + return ( + <View testID={testID} style={style}> + {children} + </View> + ) + } + + const isNoPwi = !!modui.blurs.find( + cause => + cause.type === 'label' && + cause.labelDef.identifier === '!no-unauthenticated', + ) + return ( + <CenteredView + style={[ + a.flex_1, + { + paddingTop: 100, + paddingBottom: 150, + }, + t.atoms.bg, + containerStyle, + ]} + sideBorders> + <View style={[a.align_center, a.mb_md]}> + <View + style={[ + t.atoms.bg_contrast_975, + a.align_center, + a.justify_center, + { + borderRadius: 25, + width: 50, + height: 50, + }, + ]}> + <desc.icon width={24} fill={t.atoms.bg.backgroundColor} /> + </View> + </View> + <Text + style={[ + a.text_4xl, + a.font_semibold, + a.text_center, + a.mb_md, + t.atoms.text, + ]}> + {isNoPwi ? ( + <Trans>Sign-in Required</Trans> + ) : ( + <Trans>Content Warning</Trans> + )} + </Text> + <Text + style={[ + a.text_lg, + a.mb_md, + a.px_lg, + a.text_center, + t.atoms.text_contrast_medium, + ]}> + {isNoPwi ? ( + <Trans> + This account has requested that users sign in to view their profile. + </Trans> + ) : ( + <> + <Trans>This {screenDescription} has been flagged:</Trans> + <Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}> + {desc.name}.{' '} + </Text> + <TouchableWithoutFeedback + onPress={() => { + control.open() + }} + accessibilityRole="button" + accessibilityLabel={_(msg`Learn more about this warning`)} + accessibilityHint=""> + <Text style={[a.text_lg, {color: t.palette.primary_500}]}> + <Trans>Learn More</Trans> + </Text> + </TouchableWithoutFeedback> + + <ModerationDetailsDialog control={control} modcause={blur} /> + </> + )}{' '} + </Text> + {isMobile && <View style={a.flex_1} />} + <View style={[a.flex_row, a.justify_center, a.my_md, a.gap_md]}> + <Button + variant="solid" + color="primary" + size="large" + style={[a.rounded_full]} + label={_(msg`Go back`)} + onPress={() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }}> + <ButtonText> + <Trans>Go back</Trans> + </ButtonText> + </Button> + {!modui.noOverride && ( + <Button + variant="solid" + color="secondary" + size="large" + style={[a.rounded_full]} + label={_(msg`Show anyway`)} + onPress={() => setOverride(v => !v)}> + <ButtonText> + <Trans>Show anyway</Trans> + </ButtonText> + </Button> + )} + </View> + </CenteredView> + ) +} |