diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Button.tsx | 48 | ||||
-rw-r--r-- | src/components/Dialog/index.tsx | 68 | ||||
-rw-r--r-- | src/components/Link.tsx | 14 | ||||
-rw-r--r-- | src/components/Lists.tsx | 246 | ||||
-rw-r--r-- | src/components/RichText.tsx | 14 | ||||
-rw-r--r-- | src/components/TagMenu/index.tsx | 60 | ||||
-rw-r--r-- | src/components/TagMenu/index.web.tsx | 45 | ||||
-rw-r--r-- | src/components/dialogs/MutedWords.tsx | 323 | ||||
-rw-r--r-- | src/components/forms/TextField.tsx | 4 | ||||
-rw-r--r-- | src/components/forms/Toggle.tsx | 4 |
10 files changed, 584 insertions, 242 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx index e401bda2a..5361be963 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -165,7 +165,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 @@ -174,7 +174,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') { @@ -191,20 +191,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_50, }) hoverStyles.push({ - backgroundColor: light - ? tokens.color.gray_100 - : tokens.color.gray_950, + backgroundColor: t.palette.contrast_100, }) } else { baseStyles.push({ - backgroundColor: light - ? tokens.color.gray_200 - : tokens.color.gray_950, + backgroundColor: t.palette.contrast_200, }) } } else if (variant === 'outline') { @@ -214,21 +208,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, }) } } @@ -236,14 +228,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') { @@ -253,7 +245,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 @@ -273,7 +265,7 @@ export function Button({ hoverStyles.push({ backgroundColor: light ? t.palette.negative_100 - : t.palette.negative_950, + : t.palette.negative_975, }) } } @@ -461,31 +453,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, }) } } diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 6dfc24f3b..ef4f4741b 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,12 +1,15 @@ import React, {useImperativeHandle} from 'react' -import {View, Dimensions} from 'react-native' +import {View, Dimensions, Keyboard, Pressable} from 'react-native' import BottomSheet, { - BottomSheetBackdrop, + BottomSheetBackdropProps, BottomSheetScrollView, BottomSheetTextInput, BottomSheetView, + useBottomSheet, + WINDOW_HEIGHT, } from '@gorhom/bottom-sheet' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import Animated, {useAnimatedStyle} from 'react-native-reanimated' import {useTheme, atoms as a, flatten} from '#/alf' import {Portal} from '#/components/Portal' @@ -26,6 +29,47 @@ 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, @@ -78,6 +122,7 @@ export function Outer({ const onChange = React.useCallback( (index: number) => { if (index === -1) { + Keyboard.dismiss() try { closeCallback.current?.() } catch (e: any) { @@ -113,15 +158,7 @@ export function Outer({ ref={sheet} index={openIndex} backgroundStyle={{backgroundColor: 'transparent'}} - backdropComponent={props => ( - <BottomSheetBackdrop - opacity={0.4} - appearsOnIndex={0} - disappearsOnIndex={-1} - {...props} - style={[flatten(props.style), t.atoms.bg_contrast_300]} - /> - )} + backdropComponent={Backdrop} handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} handleStyle={{display: 'none'}} onChange={onChange}> @@ -190,8 +227,15 @@ export function ScrollableInner({children, style}: DialogInnerProps) { export function Handle() { const t = useTheme() + + const onTouchStart = React.useCallback(() => { + Keyboard.dismiss() + }, []) + return ( - <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}> + <View + style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]} + onTouchStart={onTouchStart}> <View style={[ a.rounded_sm, diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 0a654fed2..8c963909b 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -49,7 +49,7 @@ 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` @@ -69,7 +69,7 @@ export function useLink({ to, displayText, action = 'push', - warnOnMismatchingTextChild, + disableMismatchWarning, onPress: outerOnPress, }: BaseLinkProps & { displayText: string @@ -90,7 +90,7 @@ export function useLink({ if (exitEarlyIfFalse === false) return const requiresWarning = Boolean( - warnOnMismatchingTextChild && + !disableMismatchWarning && displayText && isExternal && linkRequiresWarning(href, displayText), @@ -148,7 +148,7 @@ export function useLink({ }, [ outerOnPress, - warnOnMismatchingTextChild, + disableMismatchWarning, displayText, isExternal, href, @@ -167,7 +167,7 @@ export function useLink({ } } -export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> & +export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> & Omit<ButtonProps, 'onPress' | 'disabled' | 'label'> /** @@ -226,7 +226,7 @@ export function InlineLink({ children, to, action = 'push', - warnOnMismatchingTextChild, + disableMismatchWarning, style, onPress: outerOnPress, download, @@ -239,7 +239,7 @@ export function InlineLink({ to, displayText: stringChildren ? children : '', action, - warnOnMismatchingTextChild, + disableMismatchWarning, onPress: outerOnPress, }) const { diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx new file mode 100644 index 000000000..12a935807 --- /dev/null +++ b/src/components/Lists.tsx @@ -0,0 +1,246 @@ +import React from 'react' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {View} from 'react-native' +import {Loader} from '#/components/Loader' +import {Trans} from '@lingui/macro' +import {cleanError} from 'lib/strings/errors' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' +import {StackActions} from '@react-navigation/native' +import {useNavigation} from '@react-navigation/core' +import {NavigationProp} from 'lib/routes/types' +import {router} from '#/routes' + +export function ListFooter({ + isFetching, + isError, + error, + onRetry, +}: { + isFetching: boolean + isError: boolean + error?: string + onRetry?: () => Promise<unknown> +}) { + const t = useTheme() + + return ( + <View + style={[ + a.w_full, + a.align_center, + a.justify_center, + a.border_t, + a.pb_lg, + t.atoms.border_contrast_low, + {height: 100}, + ]}> + {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() + + 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="Press to retry" + style={[ + a.align_center, + a.justify_center, + a.rounded_sm, + a.overflow_hidden, + a.px_md, + a.py_sm, + ]} + onPress={onRetry}> + Retry + </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, + empty, + error, + notFoundType = 'page', + onRetry, +}: { + isLoading: boolean + isEmpty: boolean + isError: boolean + empty?: string + error?: string + notFoundType?: 'page' | 'results' + onRetry?: () => Promise<unknown> +}) { + const navigation = useNavigation<NavigationProp>() + 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]) + + if (!isEmpty) return null + + return ( + <View + style={[ + a.flex_1, + a.align_center, + !gtMobile ? [a.justify_between, a.border_t] : a.gap_5xl, + t.atoms.border_contrast_low, + {paddingTop: 175, paddingBottom: 110}, + ]}> + {isLoading ? ( + <View style={[a.w_full, a.align_center, {top: 100}]}> + <Loader size="xl" /> + </View> + ) : ( + <> + <View style={[a.w_full, a.align_center, a.gap_lg]}> + <Text style={[a.font_bold, a.text_3xl]}> + {isError ? ( + <Trans>Oops!</Trans> + ) : isEmpty ? ( + <> + {notFoundType === 'results' ? ( + <Trans>No results found</Trans> + ) : ( + <Trans>Page not found</Trans> + )} + </> + ) : undefined} + </Text> + + {isError ? ( + <Text + style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}> + {error ? error : <Trans>Something went wrong!</Trans>} + </Text> + ) : isEmpty ? ( + <Text + style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}> + {empty ? ( + empty + ) : ( + <Trans> + We're sorry! We can't find the page you were looking for. + </Trans> + )} + </Text> + ) : undefined} + </View> + <View + style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}> + {isError && onRetry && ( + <Button + variant="solid" + color="primary" + label="Click here" + onPress={onRetry} + size="large" + style={[ + a.rounded_sm, + a.overflow_hidden, + {paddingVertical: 10}, + ]}> + Retry + </Button> + )} + <Button + variant="solid" + color={isError && onRetry ? 'secondary' : 'primary'} + label="Click here" + onPress={onGoBack} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + Go Back + </Button> + </View> + </> + )} + </View> + ) +} diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 3d5f08026..1a14415cf 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -105,8 +105,7 @@ export function RichText({ to={link.uri} style={[...styles, {pointerEvents: 'auto'}]} // @ts-ignore TODO - dataSet={WORD_WRAP} - warnOnMismatchingLabel> + dataSet={WORD_WRAP}> {toShortUrl(segment.text)} </InlineLink>, ) @@ -121,6 +120,7 @@ export function RichText({ <RichTextTag key={key} text={segment.text} + tag={tag.tag} style={styles} selectable={selectable} authorHandle={authorHandle} @@ -146,12 +146,14 @@ export function RichText({ } function RichTextTag({ - text: tag, + text, + tag, style, selectable, authorHandle, }: { text: string + tag: string selectable?: boolean authorHandle?: string } & TextStyleProp) { @@ -185,8 +187,8 @@ function RichTextTag({ <Text selectable={selectable} {...native({ - accessibilityLabel: _(msg`Hashtag: ${tag}`), - accessibilityHint: _(msg`Click here to open tag menu for ${tag}`), + accessibilityLabel: _(msg`Hashtag: #${tag}`), + accessibilityHint: _(msg`Click here to open tag menu for #${tag}`), accessibilityRole: isNative ? 'button' : undefined, onPress: open, onPressIn: onPressIn, @@ -214,7 +216,7 @@ function RichTextTag({ textDecorationColor: t.palette.primary_500, }, ]}> - {tag} + {text} </Text> </TagMenu> </React.Fragment> diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 2fec7a188..c9ced9a54 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -34,6 +34,10 @@ export function TagMenu({ 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 }>) { @@ -52,16 +56,16 @@ export function TagMenu({ variables: optimisticRemove, reset: resetRemove, } = useRemoveMutedWordMutation() + const displayTag = '#' + tag - const sanitizedTag = tag.replace(/^#/, '') const isMuted = Boolean( (preferences?.mutedWords?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), ) ?? optimisticUpsert?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === sanitizedTag), + !(optimisticRemove?.value === tag), ) return ( @@ -71,7 +75,7 @@ export function TagMenu({ <Dialog.Outer control={control}> <Dialog.Handle /> - <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}> + <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}> {isPreferencesLoading ? ( <View style={[a.w_full, a.align_center]}> <Loader size="lg" /> @@ -87,18 +91,14 @@ export function TagMenu({ t.atoms.bg_contrast_25, ]}> <Link - label={_(msg`Search for all posts with tag ${tag}`)} - to={makeSearchLink({query: tag})} + label={_(msg`Search for all posts with tag ${displayTag}`)} + to={makeSearchLink({query: displayTag})} onPress={e => { e.preventDefault() control.close(() => { - // @ts-ignore :ron_swanson: "I know more than you" - navigation.navigate('SearchTab', { - screen: 'Search', - params: { - q: tag, - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), }) }) @@ -128,7 +128,7 @@ export function TagMenu({ <Trans> See{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} posts </Trans> @@ -142,21 +142,19 @@ export function TagMenu({ <Link label={_( - msg`Search for all posts by @${authorHandle} with tag ${tag}`, + msg`Search for all posts by @${authorHandle} with tag ${displayTag}`, )} - to={makeSearchLink({query: tag, from: authorHandle})} + to={makeSearchLink({ + query: displayTag, + from: authorHandle, + })} onPress={e => { e.preventDefault() control.close(() => { - // @ts-ignore :ron_swanson: "I know more than you" - navigation.navigate('SearchTab', { - screen: 'Search', - params: { - q: - tag + - (authorHandle ? ` from:${authorHandle}` : ''), - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), + author: authorHandle, }) }) @@ -190,7 +188,7 @@ export function TagMenu({ See{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} posts by this user </Trans> @@ -207,22 +205,20 @@ export function TagMenu({ <Button label={ isMuted - ? _(msg`Unmute all ${tag} posts`) - : _(msg`Mute all ${tag} posts`) + ? _(msg`Unmute all ${displayTag} posts`) + : _(msg`Mute all ${displayTag} posts`) } onPress={() => { control.close(() => { if (isMuted) { resetUpsert() removeMutedWord({ - value: sanitizedTag, + value: tag, targets: ['tag'], }) } else { resetRemove() - upsertMutedWord([ - {value: sanitizedTag, targets: ['tag']}, - ]) + upsertMutedWord([{value: tag, targets: ['tag']}]) } }) }}> @@ -252,7 +248,7 @@ export function TagMenu({ ]}> {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} <Trans>posts</Trans> </Text> diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index 31187112f..a0dc2bce6 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -14,18 +14,34 @@ import { } from '#/state/queries/preferences' import {enforceLen} from '#/lib/strings/helpers' import {web} from '#/alf' +import * as Dialog from '#/components/Dialog' -export function useTagMenuControl() {} +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 sanitizedTag = tag.replace(/^#/, '') const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const {data: preferences} = usePreferencesQuery() @@ -35,22 +51,22 @@ export function TagMenu({ useRemoveMutedWordMutation() const isMuted = Boolean( (preferences?.mutedWords?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), ) ?? optimisticUpsert?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === sanitizedTag), + !(optimisticRemove?.value === tag), ) - const truncatedTag = enforceLen(tag, 15, true, 'middle') + const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') const dropdownItems = React.useMemo(() => { return [ { label: _(msg`See ${truncatedTag} posts`), onPress() { - navigation.navigate('Search', { - q: tag, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), }) }, testID: 'tagMenuSearch', @@ -66,11 +82,9 @@ export function TagMenu({ !isInvalidHandle(authorHandle) && { label: _(msg`See ${truncatedTag} posts by user`), onPress() { - navigation.navigate({ - name: 'Search', - params: { - q: tag + (authorHandle ? ` from:${authorHandle}` : ''), - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), + author: authorHandle, }) }, testID: 'tagMenuSeachByUser', @@ -91,9 +105,9 @@ export function TagMenu({ : _(msg`Mute ${truncatedTag}`), onPress() { if (isMuted) { - removeMutedWord({value: sanitizedTag, targets: ['tag']}) + removeMutedWord({value: tag, targets: ['tag']}) } else { - upsertMutedWord([{value: sanitizedTag, targets: ['tag']}]) + upsertMutedWord([{value: tag, targets: ['tag']}]) } }, testID: 'tagMenuMute', @@ -114,7 +128,6 @@ export function TagMenu({ preferences, tag, truncatedTag, - sanitizedTag, upsertMutedWord, removeMutedWord, ]) diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 7c0d4fbca..658ba2aae 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -1,8 +1,8 @@ import React from 'react' -import {View} from 'react-native' +import {Keyboard, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' import { usePreferencesQuery, @@ -10,7 +10,14 @@ import { useRemoveMutedWordMutation, } from '#/state/queries/preferences' import {isNative} from '#/platform/detection' -import {atoms as a, useTheme, useBreakpoints, ViewStyleProp, web} from '#/alf' +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' @@ -48,166 +55,208 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() const [field, setField] = React.useState('') const [options, setOptions] = React.useState(['content']) - const [_error, setError] = React.useState('') + const [error, setError] = React.useState('') const submit = React.useCallback(async () => { - const value = field.trim() + const sanitizedValue = sanitizeMutedWordValue(field) const targets = ['tag', options.includes('content') && 'content'].filter( Boolean, ) as AppBskyActorDefs.MutedWord['targets'] - if (!value || !targets.length) return + if (!sanitizedValue || !targets.length) { + setField('') + setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) + return + } try { - await addMutedWord([{value, targets}]) + // 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]) + }, [_, field, options, addMutedWord, setField]) return ( <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> - <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 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={setField} - onSubmitEditing={submit} - /> + <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.pb_md, - 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.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> + <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> + <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> - <Text - style={[ - 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> + {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> + )} - <Divider /> + <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> - <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> + <Divider /> - {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.mutedWords.length ? ( - [...preferences.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> + <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.mutedWords.length ? ( + [...preferences.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}} />} + {isNative && <View style={{height: 20}} />} - <Dialog.Close /> + <Dialog.Close /> + </View> </Dialog.ScrollableInner> ) } diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index a781bdd18..b37f4bfae 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -10,7 +10,7 @@ import { } from 'react-native' import {HITSLOP_20} from 'lib/constants' -import {useTheme, atoms as a, web, tokens, android} from '#/alf' +import {useTheme, atoms as a, web, android} from '#/alf' import {Text} from '#/components/Typography' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Props as SVGIconProps} from '#/components/icons/common' @@ -110,7 +110,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, }, ] diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 140740f70..a83f92a2a 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -301,7 +301,7 @@ 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, }) @@ -310,7 +310,7 @@ export function createSharedToggleStyles({ baseHover.push({ backgroundColor: t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, - borderColor: t.palette.negative_500, + borderColor: t.palette.negative_600, }) } } |