diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Dialog/index.web.tsx | 2 | ||||
-rw-r--r-- | src/components/RichText.tsx | 103 | ||||
-rw-r--r-- | src/components/TagMenu/index.tsx | 279 | ||||
-rw-r--r-- | src/components/TagMenu/index.web.tsx | 127 | ||||
-rw-r--r-- | src/components/dialogs/Context.tsx | 29 | ||||
-rw-r--r-- | src/components/dialogs/MutedWords.tsx | 328 | ||||
-rw-r--r-- | src/components/forms/TextField.tsx | 2 | ||||
-rw-r--r-- | src/components/forms/Toggle.tsx | 34 | ||||
-rw-r--r-- | src/components/icons/Check.tsx | 4 | ||||
-rw-r--r-- | src/components/icons/Clipboard.tsx | 5 | ||||
-rw-r--r-- | src/components/icons/Group3.tsx | 2 | ||||
-rw-r--r-- | src/components/icons/MagnifyingGlass2.tsx | 5 | ||||
-rw-r--r-- | src/components/icons/Mute.tsx | 5 | ||||
-rw-r--r-- | src/components/icons/PageText.tsx | 5 | ||||
-rw-r--r-- | src/components/icons/Person.tsx | 5 |
15 files changed, 907 insertions, 28 deletions
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 79441fb5e..fa29fbd6c 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -188,7 +188,7 @@ export function Close() { <Button size="small" variant="ghost" - color="primary" + color="secondary" shape="round" onPress={close} label={_(msg`Close active dialog`)}> diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index c72fcabdd..22391cb24 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,11 +1,16 @@ 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, flatten} from '#/alf' +import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf' import {InlineLink} from '#/components/Link' 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} @@ -17,6 +22,8 @@ export function RichText({ disableLinks, resolveFacets = false, selectable, + enableTags = false, + authorHandle, }: TextStyleProp & Pick<TextProps, 'selectable'> & { value: RichTextAPI | string @@ -24,6 +31,8 @@ export function RichText({ numberOfLines?: number disableLinks?: boolean resolveFacets?: boolean + enableTags?: boolean + authorHandle?: string }) { const detected = React.useRef(false) const [richText, setRichText] = React.useState<RichTextAPI>(() => @@ -85,6 +94,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 && @@ -118,6 +128,21 @@ export function RichText({ </InlineLink>, ) } + } else if ( + !disableLinks && + enableTags && + tag && + AppBskyRichtextFacet.validateTag(tag).success + ) { + els.push( + <RichTextTag + key={key} + text={segment.text} + style={styles} + selectable={selectable} + authorHandle={authorHandle} + />, + ) } else { els.push(segment.text) } @@ -136,3 +161,79 @@ export function RichText({ </Text> ) } + +function RichTextTag({ + text: tag, + style, + selectable, + authorHandle, +}: { + text: 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, + }, + ]}> + {tag} + </Text> + </TagMenu> + </React.Fragment> + ) +} diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx new file mode 100644 index 000000000..2fec7a188 --- /dev/null +++ b/src/components/TagMenu/index.tsx @@ -0,0 +1,279 @@ +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'] + 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 sanitizedTag = tag.replace(/^#/, '') + const isMuted = Boolean( + (preferences?.mutedWords?.find( + m => m.value === sanitizedTag && m.targets.includes('tag'), + ) ?? + optimisticUpsert?.find( + m => m.value === sanitizedTag && m.targets.includes('tag'), + )) && + !(optimisticRemove?.value === sanitizedTag), + ) + + return ( + <> + {children} + + <Dialog.Outer control={control}> + <Dialog.Handle /> + + <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}> + {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 ${tag}`)} + to={makeSearchLink({query: tag})} + onPress={e => { + e.preventDefault() + + control.close(() => { + // @ts-ignore :ron_swanson: "I know more than you" + navigation.navigate('SearchTab', { + screen: 'Search', + params: { + q: 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]}> + {tag} + </Text>{' '} + posts + </Trans> + </Text> + </View> + </Link> + + {authorHandle && !isInvalidHandle(authorHandle) && ( + <> + <Divider /> + + <Link + label={_( + msg`Search for all posts by @${authorHandle} with tag ${tag}`, + )} + to={makeSearchLink({query: tag, 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}` : ''), + }, + }) + }) + + 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]}> + {tag} + </Text>{' '} + posts by this user + </Trans> + </Text> + </View> + </Link> + </> + )} + + {preferences ? ( + <> + <Divider /> + + <Button + label={ + isMuted + ? _(msg`Unmute all ${tag} posts`) + : _(msg`Mute all ${tag} posts`) + } + onPress={() => { + control.close(() => { + if (isMuted) { + resetUpsert() + removeMutedWord({ + value: sanitizedTag, + targets: ['tag'], + }) + } else { + resetRemove() + upsertMutedWord([ + {value: sanitizedTag, 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]}> + {tag} + </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>Cancel</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..930e47a1a --- /dev/null +++ b/src/components/TagMenu/index.web.tsx @@ -0,0 +1,127 @@ +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' + +export function useTagMenuControl() {} + +export function TagMenu({ + children, + tag, + authorHandle, +}: React.PropsWithChildren<{ + tag: string + authorHandle?: string +}>) { + const sanitizedTag = tag.replace(/^#/, '') + 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?.mutedWords?.find( + m => m.value === sanitizedTag && m.targets.includes('tag'), + ) ?? + optimisticUpsert?.find( + m => m.value === sanitizedTag && m.targets.includes('tag'), + )) && + !(optimisticRemove?.value === sanitizedTag), + ) + + const dropdownItems = React.useMemo(() => { + return [ + { + label: _(msg`See ${tag} posts`), + onPress() { + navigation.navigate('Search', { + q: tag, + }) + }, + testID: 'tagMenuSearch', + icon: { + ios: { + name: 'magnifyingglass', + }, + android: '', + web: 'magnifying-glass', + }, + }, + authorHandle && + !isInvalidHandle(authorHandle) && { + label: _(msg`See ${tag} posts by this user`), + onPress() { + navigation.navigate({ + name: 'Search', + params: { + q: tag + (authorHandle ? ` from:${authorHandle}` : ''), + }, + }) + }, + testID: 'tagMenuSeachByUser', + icon: { + ios: { + name: 'magnifyingglass', + }, + android: '', + web: ['far', 'user'], + }, + }, + preferences && { + label: 'separator', + }, + preferences && { + label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`), + onPress() { + if (isMuted) { + removeMutedWord({value: sanitizedTag, targets: ['tag']}) + } else { + upsertMutedWord([{value: sanitizedTag, 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, + sanitizedTag, + upsertMutedWord, + removeMutedWord, + ]) + + return ( + <EventStopper> + <NativeDropdown + accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)} + accessibilityHint="" + // @ts-ignore + items={dropdownItems}> + {children} + </NativeDropdown> + </EventStopper> + ) +} diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx new file mode 100644 index 000000000..d86c90a92 --- /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( + () => ({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..138cc5330 --- /dev/null +++ b/src/components/dialogs/MutedWords.tsx @@ -0,0 +1,328 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyActorDefs} from '@atproto/api' + +import { + usePreferencesQuery, + useUpsertMutedWordsMutation, + useRemoveMutedWordMutation, +} from '#/state/queries/preferences' +import {isNative} from '#/platform/detection' +import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} 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 value = field.trim() + const targets = ['tag', options.includes('content') && 'content'].filter( + Boolean, + ) as AppBskyActorDefs.MutedWord['targets'] + + if (!value || !targets.length) return + + try { + await addMutedWord([{value, 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`)}> + <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} + /> + + <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.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> + + <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> + + <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.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}} />} + + <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.Outer control={control}> + <Prompt.Title> + <Trans>Are you sure?</Trans> + </Prompt.Title> + <Prompt.Description> + <Trans> + This will delete {word.value} from your muted words. You can always + add it back later. + </Trans> + </Prompt.Description> + <Prompt.Actions> + <Prompt.Cancel> + <ButtonText> + <Trans>Nevermind</Trans> + </ButtonText> + </Prompt.Cancel> + <Prompt.Action onPress={remove}> + <ButtonText> + <Trans>Remove</Trans> + </ButtonText> + </Prompt.Action> + </Prompt.Actions> + </Prompt.Outer> + + <View + style={[ + a.py_md, + a.px_lg, + a.flex_row, + a.align_center, + a.justify_between, + a.rounded_md, + style, + ]}> + <Text style={[a.font_bold, t.atoms.text_contrast_high]}> + {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/TextField.tsx b/src/components/forms/TextField.tsx index ebf2e4750..a781bdd18 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -72,7 +72,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, diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 9369423f2..140740f70 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants' import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} 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 @@ -331,15 +332,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={[ @@ -355,21 +355,7 @@ export function Checkbox() { baseStyles, hovered || focused ? 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> ) } 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/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/Group3.tsx b/src/components/icons/Group3.tsx index 2bb16ba87..9e5ab8893 100644 --- a/src/components/icons/Group3.tsx +++ b/src/components/icons/Group3.tsx @@ -1,5 +1,5 @@ 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', + 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/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/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', +}) |