diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/Link.tsx | 126 | ||||
-rw-r--r-- | src/components/Menu/index.tsx | 9 | ||||
-rw-r--r-- | src/components/Menu/index.web.tsx | 8 | ||||
-rw-r--r-- | src/components/Menu/types.ts | 5 | ||||
-rw-r--r-- | src/components/RichText.tsx | 94 | ||||
-rw-r--r-- | src/components/RichTextTag.tsx | 160 | ||||
-rw-r--r-- | src/components/TagMenu/index.tsx | 290 | ||||
-rw-r--r-- | src/components/TagMenu/index.web.tsx | 163 | ||||
-rw-r--r-- | src/platform/urls.tsx | 14 | ||||
-rw-r--r-- | src/view/com/feeds/FeedSourceCard.tsx | 2 |
10 files changed, 294 insertions, 577 deletions
diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 3cd593a10..50e741ea7 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -15,7 +15,6 @@ import { linkRequiresWarning, } from '#/lib/strings/url-helpers' import {isNative, isWeb} from '#/platform/detection' -import {shouldClickOpenNewTab} from '#/platform/urls' import {useModalControls} from '#/state/modals' import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf' import {Button, ButtonProps} from '#/components/Button' @@ -56,6 +55,12 @@ type BaseLinkProps = Pick< onPress?: (e: GestureResponderEvent) => void | false /** + * Callback for when the link is long pressed (on native). Prevent default + * and return `false` to exit early and prevent default long press hander. + */ + onLongPress?: (e: GestureResponderEvent) => void | false + + /** * Web-only attribute. Sets `download` attr on web. */ download?: string @@ -72,6 +77,7 @@ export function useLink({ action = 'push', disableMismatchWarning, onPress: outerOnPress, + onLongPress: outerOnLongPress, shareOnLongPress, }: BaseLinkProps & { displayText: string @@ -175,8 +181,14 @@ export function useLink({ } }, [disableMismatchWarning, displayText, href, isExternal, openModal]) - const onLongPress = - isNative && isExternal && shareOnLongPress ? handleLongPress : undefined + const onLongPress = React.useCallback( + (e: GestureResponderEvent) => { + const exitEarlyIfFalse = outerOnLongPress?.(e) + if (exitEarlyIfFalse === false) return + return isNative && shareOnLongPress ? handleLongPress() : undefined + }, + [outerOnLongPress, handleLongPress, shareOnLongPress], + ) return { isExternal, @@ -202,14 +214,16 @@ export function Link({ to, action = 'push', onPress: outerOnPress, + onLongPress: outerOnLongPress, download, ...rest }: LinkProps) { - const {href, isExternal, onPress} = useLink({ + const {href, isExternal, onPress, onLongPress} = useLink({ to, displayText: typeof children === 'string' ? children : '', action, onPress: outerOnPress, + onLongPress: outerOnLongPress, }) return ( @@ -220,6 +234,7 @@ export function Link({ accessibilityRole="link" href={href} onPress={download ? undefined : onPress} + onLongPress={onLongPress} {...web({ hrefAttrs: { target: download ? undefined : isExternal ? 'blank' : undefined, @@ -241,7 +256,7 @@ export type InlineLinkProps = React.PropsWithChildren< TextStyleProp & Pick<TextProps, 'selectable' | 'numberOfLines'> > & - Pick<ButtonProps, 'label'> & { + Pick<ButtonProps, 'label' | 'accessibilityHint'> & { disableUnderline?: boolean title?: TextProps['title'] } @@ -253,6 +268,7 @@ export function InlineLinkText({ disableMismatchWarning, style, onPress: outerOnPress, + onLongPress: outerOnLongPress, download, selectable, label, @@ -268,6 +284,7 @@ export function InlineLinkText({ action, disableMismatchWarning, onPress: outerOnPress, + onLongPress: outerOnLongPress, shareOnLongPress, }) const { @@ -319,6 +336,21 @@ export function InlineLinkText({ ) } +export function WebOnlyInlineLinkText({ + children, + to, + onPress, + ...props +}: Omit<InlineLinkProps, 'onLongPress'>) { + return isWeb ? ( + <InlineLinkText {...props} to={to} onPress={onPress}> + {children} + </InlineLinkText> + ) : ( + <Text {...props}>{children}</Text> + ) +} + /** * Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI * @@ -327,7 +359,10 @@ export function InlineLinkText({ */ export function createStaticClick( onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>, -): Pick<BaseLinkProps, 'to' | 'onPress'> { +): { + to: BaseLinkProps['to'] + onPress: Exclude<BaseLinkProps['onPress'], undefined> +} { return { to: '#', onPress(e: GestureResponderEvent) { @@ -338,17 +373,72 @@ export function createStaticClick( } } -export function WebOnlyInlineLinkText({ - children, - to, - onPress, - ...props -}: InlineLinkProps) { - return isWeb ? ( - <InlineLinkText {...props} to={to} onPress={onPress}> - {children} - </InlineLinkText> - ) : ( - <Text {...props}>{children}</Text> +/** + * Utility to create a static `onPress` handler for a `Link`, but only if the + * click was not modified in some way e.g. `Cmd` or a middle click. + * + * On native, this behaves the same as `createStaticClick` because there are no + * options to "modify" the click in this sense. + * + * Example: + * `<Link {...createStaticClick(e => {...})} />` + */ +export function createStaticClickIfUnmodified( + onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>, +): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} { + return { + onPress(e: GestureResponderEvent) { + if (!isWeb || !isModifiedClickEvent(e)) { + e.preventDefault() + onPressHandler(e) + return false + } + }, + } +} + +/** + * Determines if the click event has a meta key pressed, indicating the user + * intends to deviate from default behavior. + */ +export function isClickEventWithMetaKey(e: GestureResponderEvent) { + if (!isWeb) return false + const event = e as unknown as MouseEvent + return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey +} + +/** + * Determines if the web click target is anything other than `_self` + */ +export function isClickTargetExternal(e: GestureResponderEvent) { + if (!isWeb) return false + const event = e as unknown as MouseEvent + const el = event.currentTarget as HTMLAnchorElement + return el && el.target && el.target !== '_self' +} + +/** + * Determines if a click event has been modified in some way from its default + * behavior, e.g. `Cmd` or a middle click. + * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button} + */ +export function isModifiedClickEvent(e: GestureResponderEvent): boolean { + if (!isWeb) return false + const event = e as unknown as MouseEvent + const isPrimaryButton = event.button === 0 + return ( + isClickEventWithMetaKey(e) || isClickTargetExternal(e) || !isPrimaryButton ) } + +/** + * Determines if a click event has been modified in a way that should indiciate + * that the user intends to open a new tab. + * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button} + */ +export function shouldClickOpenNewTab(e: GestureResponderEvent) { + if (!isWeb) return false + const event = e as unknown as MouseEvent + const isMiddleClick = isWeb && event.button === 1 + return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick +} diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 99fb2d127..9c970b051 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -47,7 +47,12 @@ export function Root({ return <Context.Provider value={context}>{children}</Context.Provider> } -export function Trigger({children, label, role = 'button'}: TriggerProps) { +export function Trigger({ + children, + label, + role = 'button', + hint, +}: TriggerProps) { const context = useMenuContext() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const { @@ -65,11 +70,13 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) { pressed, }, props: { + ref: null, onPress: context.control.open, onFocus, onBlur, onPressIn, onPressOut, + accessibilityHint: hint, accessibilityLabel: label, accessibilityRole: role, }, diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index d1863e478..dc9116168 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -110,7 +110,12 @@ const RadixTriggerPassThrough = React.forwardRef( ) RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough' -export function Trigger({children, label, role = 'button'}: TriggerProps) { +export function Trigger({ + children, + label, + role = 'button', + hint, +}: TriggerProps) { const {control} = useMenuContext() const { state: hovered, @@ -153,6 +158,7 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) { onBlur: onBlur, onMouseEnter, onMouseLeave, + accessibilityHint: hint, accessibilityLabel: label, accessibilityRole: role, }, diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts index 44171d42c..51baa24df 100644 --- a/src/components/Menu/types.ts +++ b/src/components/Menu/types.ts @@ -19,6 +19,7 @@ export type ItemContextType = { } export type RadixPassThroughTriggerProps = { + ref: React.RefObject<any> id: string type: 'button' disabled: boolean @@ -37,6 +38,7 @@ export type RadixPassThroughTriggerProps = { export type TriggerProps = { children(props: TriggerChildProps): React.ReactNode label: string + hint?: string role?: AccessibilityRole } export type TriggerChildProps = @@ -59,11 +61,13 @@ export type TriggerChildProps = * object is empty. */ props: { + ref: null onPress: () => void onFocus: () => void onBlur: () => void onPressIn: () => void onPressOut: () => void + accessibilityHint?: string accessibilityLabel: string accessibilityRole: AccessibilityRole } @@ -85,6 +89,7 @@ export type TriggerChildProps = onBlur: () => void onMouseEnter: () => void onMouseLeave: () => void + accessibilityHint?: string accessibilityLabel: string accessibilityRole: AccessibilityRole } diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 4edd9f88e..7005d0742 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,19 +1,13 @@ import React from 'react' import {TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from '#/lib/routes/types' import {toShortUrl} from '#/lib/strings/url-helpers' -import {isNative} from '#/platform/detection' -import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf' +import {atoms as a, flatten, TextStyleProp} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' -import {useInteractionState} from '#/components/hooks/useInteractionState' import {InlineLinkText, LinkProps} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' -import {TagMenu, useTagMenuControl} from '#/components/TagMenu' +import {RichTextTag} from '#/components/RichTextTag' import {Text, TextProps} from '#/components/Typography' const WORD_WRAP = {wordWrap: 1} @@ -149,10 +143,9 @@ export function RichText({ els.push( <RichTextTag key={key} - text={segment.text} + display={segment.text} tag={tag.tag} - style={interactiveStyles} - selectable={selectable} + textStyle={interactiveStyles} authorHandle={authorHandle} />, ) @@ -177,82 +170,3 @@ 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 navigation = useNavigation<NavigationProp>() - - const navigateToPage = React.useCallback(() => { - navigation.push('Hashtag', { - tag: encodeURIComponent(tag), - }) - }, [navigation, tag]) - - const openDialog = 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 - emoji - selectable={selectable} - {...native({ - accessibilityLabel: _(msg`Hashtag: #${tag}`), - accessibilityHint: _(msg`Long press to open tag menu for #${tag}`), - accessibilityRole: isNative ? 'button' : undefined, - onPress: navigateToPage, - onLongPress: openDialog, - })} - {...web({ - onMouseEnter: onHoverIn, - onMouseLeave: onHoverOut, - })} - // @ts-ignore - onFocus={onFocus} - onBlur={onBlur} - style={[ - web({ - cursor: 'pointer', - }), - {color: t.palette.primary_500}, - (hovered || focused) && { - ...web({ - outline: 0, - textDecorationLine: 'underline', - textDecorationColor: t.palette.primary_500, - }), - }, - style, - ]}> - {text} - </Text> - </TagMenu> - </React.Fragment> - ) -} diff --git a/src/components/RichTextTag.tsx b/src/components/RichTextTag.tsx new file mode 100644 index 000000000..562d44aa6 --- /dev/null +++ b/src/components/RichTextTag.tsx @@ -0,0 +1,160 @@ +import React from 'react' +import {StyleProp, Text as RNText, TextStyle} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {NavigationProp} from '#/lib/routes/types' +import {isInvalidHandle} from '#/lib/strings/handles' +import {isNative, isWeb} from '#/platform/detection' +import { + usePreferencesQuery, + useRemoveMutedWordsMutation, + useUpsertMutedWordsMutation, +} from '#/state/queries/preferences' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import { + createStaticClick, + createStaticClickIfUnmodified, + InlineLinkText, +} from '#/components/Link' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' + +export function RichTextTag({ + tag, + display, + authorHandle, + textStyle, +}: { + tag: string + display: string + authorHandle?: string + textStyle: StyleProp<TextStyle> +}) { + const {_} = useLingui() + const {isLoading: isPreferencesLoading, data: preferences} = + usePreferencesQuery() + const { + mutateAsync: upsertMutedWord, + variables: optimisticUpsert, + reset: resetUpsert, + } = useUpsertMutedWordsMutation() + const { + mutateAsync: removeMutedWords, + variables: optimisticRemove, + reset: resetRemove, + } = useRemoveMutedWordsMutation() + const navigation = useNavigation<NavigationProp>() + const label = _(msg`Hashtag ${tag}`) + const hint = isNative + ? _(msg`Long press to open tag menu for #${tag}`) + : _(msg`Click to open tag menu for ${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?.find(m => m?.value === tag), + ) + + /* + * Mute word records that exactly match the tag in question. + */ + const removeableMuteWords = React.useMemo(() => { + return ( + preferences?.moderationPrefs.mutedWords?.filter(word => { + return word.value === tag + }) || [] + ) + }, [tag, preferences?.moderationPrefs?.mutedWords]) + + return ( + <Menu.Root> + <Menu.Trigger label={label} hint={hint}> + {({props: menuProps}) => ( + <InlineLinkText + to={{ + screen: 'Hashtag', + params: {tag: encodeURIComponent(tag)}, + }} + {...menuProps} + onPress={e => { + if (isWeb) { + return createStaticClickIfUnmodified(() => { + if (!isNative) { + menuProps.onPress() + } + }).onPress(e) + } + }} + onLongPress={createStaticClick(menuProps.onPress).onPress} + accessibilityHint={hint} + label={label} + style={textStyle}> + {isNative ? ( + display + ) : ( + <RNText ref={menuProps.ref}>{display}</RNText> + )} + </InlineLinkText> + )} + </Menu.Trigger> + <Menu.Outer> + <Menu.Group> + <Menu.Item + label={_(msg`See ${tag} posts`)} + onPress={() => { + navigation.push('Hashtag', { + tag: encodeURIComponent(tag), + }) + }}> + <Menu.ItemText> + <Trans>See #{tag} posts</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Search} /> + </Menu.Item> + {authorHandle && !isInvalidHandle(authorHandle) && ( + <Menu.Item + label={_(msg`See ${tag} posts by user`)} + onPress={() => { + navigation.push('Hashtag', { + tag: encodeURIComponent(tag), + author: authorHandle, + }) + }}> + <Menu.ItemText> + <Trans>See #{tag} posts by user</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Person} /> + </Menu.Item> + )} + </Menu.Group> + <Menu.Divider /> + <Menu.Item + label={isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)} + onPress={() => { + if (isMuted) { + resetUpsert() + removeMutedWords(removeableMuteWords) + } else { + resetRemove() + upsertMutedWord([ + {value: tag, targets: ['tag'], actorTarget: 'all'}, + ]) + } + }}> + <Menu.ItemText> + {isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)} + </Menu.ItemText> + <Menu.ItemIcon icon={isPreferencesLoading ? Loader : Mute} /> + </Menu.Item> + </Menu.Outer> + </Menu.Root> + ) +} diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx deleted file mode 100644 index 310ecc4c2..000000000 --- a/src/components/TagMenu/index.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' - -import {NavigationProp} from '#/lib/routes/types' -import {isInvalidHandle} from '#/lib/strings/handles' -import { - usePreferencesQuery, - useRemoveMutedWordsMutation, - useUpsertMutedWordsMutation, -} from '#/state/queries/preferences' -import {atoms as a, native, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import {Divider} from '#/components/Divider' -import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' -import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' -import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' -import {createStaticClick, Link} from '#/components/Link' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' - -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 navigation = useNavigation<NavigationProp>() - return ( - <> - {children} - <Dialog.Outer control={control}> - <Dialog.Handle /> - <TagMenuInner - control={control} - tag={tag} - authorHandle={authorHandle} - navigation={navigation} - /> - </Dialog.Outer> - </> - ) -} - -function TagMenuInner({ - control, - tag, - authorHandle, - navigation, -}: { - control: Dialog.DialogOuterProps['control'] - tag: string - authorHandle?: string - // Passed down because on native, we don't use real portals (and context would be wrong). - navigation: NavigationProp -}) { - const {_} = useLingui() - const t = useTheme() - const {isLoading: isPreferencesLoading, data: preferences} = - usePreferencesQuery() - const { - mutateAsync: upsertMutedWord, - variables: optimisticUpsert, - reset: resetUpsert, - } = useUpsertMutedWordsMutation() - const { - mutateAsync: removeMutedWords, - variables: optimisticRemove, - reset: resetRemove, - } = useRemoveMutedWordsMutation() - 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?.find(m => m?.value === tag), - ) - - /* - * Mute word records that exactly match the tag in question. - */ - const removeableMuteWords = React.useMemo(() => { - return ( - preferences?.moderationPrefs.mutedWords?.filter(word => { - return word.value === tag - }) || [] - ) - }, [tag, preferences?.moderationPrefs?.mutedWords]) - - return ( - <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`View all posts with tag ${displayTag}`)} - {...createStaticClick(() => { - control.close(() => { - navigation.push('Hashtag', { - tag: encodeURIComponent(tag), - }) - }) - })}> - <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`View all posts by @${authorHandle} with tag ${displayTag}`, - )} - {...createStaticClick(() => { - control.close(() => { - navigation.push('Hashtag', { - tag: encodeURIComponent(tag), - author: authorHandle, - }) - }) - })}> - <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() - removeMutedWords(removeableMuteWords) - } else { - resetRemove() - upsertMutedWord([ - { - value: tag, - targets: ['tag'], - actorTarget: 'all', - }, - ]) - } - }) - }}> - <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> - ) -} diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx deleted file mode 100644 index b6c306439..000000000 --- a/src/components/TagMenu/index.web.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React from 'react' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' - -import {NavigationProp} from '#/lib/routes/types' -import {isInvalidHandle} from '#/lib/strings/handles' -import {enforceLen} from '#/lib/strings/helpers' -import { - usePreferencesQuery, - useRemoveMutedWordsMutation, - useUpsertMutedWordsMutation, -} from '#/state/queries/preferences' -import {EventStopper} from '#/view/com/util/EventStopper' -import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' -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: removeMutedWords, variables: optimisticRemove} = - useRemoveMutedWordsMutation() - 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?.find(m => m?.value === tag), - ) - const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') - - /* - * Mute word records that exactly match the tag in question. - */ - const removeableMuteWords = React.useMemo(() => { - return ( - preferences?.moderationPrefs.mutedWords?.filter(word => { - return word.value === tag - }) || [] - ) - }, [tag, preferences?.moderationPrefs?.mutedWords]) - - 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) { - removeMutedWords(removeableMuteWords) - } else { - upsertMutedWord([ - {value: tag, targets: ['tag'], actorTarget: 'all'}, - ]) - } - }, - 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, - removeMutedWords, - removeableMuteWords, - ]) - - 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/platform/urls.tsx b/src/platform/urls.tsx index fd9d297aa..514bde43e 100644 --- a/src/platform/urls.tsx +++ b/src/platform/urls.tsx @@ -1,4 +1,4 @@ -import {GestureResponderEvent, Linking} from 'react-native' +import {Linking} from 'react-native' import {isNative, isWeb} from './detection' @@ -24,15 +24,3 @@ export function clearHash() { window.location.hash = '' } } - -export function shouldClickOpenNewTab(e: GestureResponderEvent) { - /** - * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch - * of @ts-ignore below. - */ - const event = e as any - const isMiddleClick = isWeb && event.button === 1 - const isMetaKey = - isWeb && (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) - return isMetaKey || isMiddleClick -} diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index a59148889..b0b608f17 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -17,7 +17,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {sanitizeHandle} from '#/lib/strings/handles' import {s} from '#/lib/styles' import {logger} from '#/logger' -import {shouldClickOpenNewTab} from '#/platform/urls' import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' import { useAddSavedFeedsMutation, @@ -29,6 +28,7 @@ import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import * as Toast from '#/view/com/util/Toast' import {useTheme} from '#/alf' import {atoms as a} from '#/alf' +import {shouldClickOpenNewTab} from '#/components/Link' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {Text} from '../util/text/Text' |