diff options
Diffstat (limited to 'src/components/Link.tsx')
-rw-r--r-- | src/components/Link.tsx | 185 |
1 files changed, 103 insertions, 82 deletions
diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 763f07ca9..7d0e83332 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -1,21 +1,13 @@ import React from 'react' -import { - GestureResponderEvent, - Linking, - TouchableWithoutFeedback, -} from 'react-native' -import { - useLinkProps, - useNavigation, - StackActions, -} from '@react-navigation/native' +import {GestureResponderEvent} from 'react-native' +import {useLinkProps, StackActions} from '@react-navigation/native' import {sanitizeUrl} from '@braintree/sanitize-url' import {useInteractionState} from '#/components/hooks/useInteractionState' import {isWeb} from '#/platform/detection' -import {useTheme, web, flatten, TextStyleProp} from '#/alf' +import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf' import {Button, ButtonProps} from '#/components/Button' -import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {AllNavigatorParams} from '#/lib/routes/types' import { convertBskyAppUrlIfNeeded, isExternalUrl, @@ -23,7 +15,9 @@ import { } from '#/lib/strings/url-helpers' import {useModalControls} from '#/state/modals' import {router} from '#/routes' -import {Text} from '#/components/Typography' +import {Text, TextProps} from '#/components/Typography' +import {useOpenLink} from 'state/preferences/in-app-browser' +import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' /** * Only available within a `Link`, since that inherits from `Button`. @@ -35,6 +29,13 @@ type BaseLinkProps = Pick< Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to' > & { + testID?: string + + /** + * Label for a11y. Defaults to the href. + */ + label?: string + /** * The React Navigation `StackAction` to perform when the link is pressed. */ @@ -45,29 +46,48 @@ type BaseLinkProps = Pick< * * Note: atm this only works for `InlineLink`s with a string child. */ - warnOnMismatchingTextChild?: boolean + disableMismatchWarning?: boolean + + /** + * Callback for when the link is pressed. Prevent default and return `false` + * to exit early and prevent navigation. + * + * DO NOT use this for navigation, that's what the `to` prop is for. + */ + onPress?: (e: GestureResponderEvent) => void | false + + /** + * Web-only attribute. Sets `download` attr on web. + */ + download?: string } export function useLink({ to, displayText, action = 'push', - warnOnMismatchingTextChild, + disableMismatchWarning, + onPress: outerOnPress, }: BaseLinkProps & { displayText: string }) { - const navigation = useNavigation<NavigationProp>() + const navigation = useNavigationDeduped() const {href} = useLinkProps<AllNavigatorParams>({ to: typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to, }) const isExternal = isExternalUrl(href) const {openModal, closeModal} = useModalControls() + const openLink = useOpenLink() const onPress = React.useCallback( (e: GestureResponderEvent) => { + const exitEarlyIfFalse = outerOnPress?.(e) + + if (exitEarlyIfFalse === false) return + const requiresWarning = Boolean( - warnOnMismatchingTextChild && + !disableMismatchWarning && displayText && isExternal && linkRequiresWarning(href, displayText), @@ -85,7 +105,7 @@ export function useLink({ e.preventDefault() if (isExternal) { - Linking.openURL(href) + openLink(href) } else { /** * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch @@ -103,7 +123,7 @@ export function useLink({ href.startsWith('http') || href.startsWith('mailto') ) { - Linking.openURL(href) + openLink(href) } else { closeModal() // close any active modals @@ -124,14 +144,16 @@ export function useLink({ } }, [ - href, - isExternal, - warnOnMismatchingTextChild, - navigation, - action, + outerOnPress, + disableMismatchWarning, displayText, - closeModal, + isExternal, + href, openModal, + openLink, + closeModal, + action, + navigation, ], ) @@ -142,17 +164,8 @@ export function useLink({ } } -export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> & - Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & { - /** - * Label for a11y. Defaults to the href. - */ - label?: string - /** - * Web-only attribute. Sets `download` attr on web. - */ - download?: string - } +export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> & + Omit<ButtonProps, 'onPress' | 'disabled' | 'label'> /** * A interactive element that renders as a `<a>` tag on the web. On mobile it @@ -166,6 +179,7 @@ export function Link({ children, to, action = 'push', + onPress: outerOnPress, download, ...rest }: LinkProps) { @@ -173,24 +187,26 @@ export function Link({ to, displayText: typeof children === 'string' ? children : '', action, + onPress: outerOnPress, }) return ( <Button label={href} {...rest} + style={[a.justify_start, flatten(rest.style)]} role="link" accessibilityRole="link" href={href} - onPress={onPress} + onPress={download ? undefined : onPress} {...web({ hrefAttrs: { - target: isExternal ? 'blank' : undefined, + target: download ? undefined : isExternal ? 'blank' : undefined, rel: isExternal ? 'noopener noreferrer' : undefined, download, }, dataSet: { - // default to no underline, apply this ourselves + // no underline, only `InlineLink` has underlines noUnderline: '1', }, })}> @@ -200,21 +216,19 @@ export function Link({ } export type InlineLinkProps = React.PropsWithChildren< - BaseLinkProps & - TextStyleProp & { - /** - * Label for a11y. Defaults to the href. - */ - label?: string - } + BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'> > export function InlineLink({ children, to, action = 'push', - warnOnMismatchingTextChild, + disableMismatchWarning, style, + onPress: outerOnPress, + download, + selectable, + label, ...rest }: InlineLinkProps) { const t = useTheme() @@ -223,52 +237,59 @@ export function InlineLink({ to, displayText: stringChildren ? children : '', action, - warnOnMismatchingTextChild, + disableMismatchWarning, + onPress: outerOnPress, }) + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const { state: pressed, onIn: onPressIn, onOut: onPressOut, } = useInteractionState() + const flattenedStyle = flatten(style) || {} return ( - <TouchableWithoutFeedback - accessibilityRole="button" - onPress={onPress} + <Text + selectable={selectable} + accessibilityHint="" + accessibilityLabel={label || href} + {...rest} + style={[ + {color: t.palette.primary_500}, + (hovered || focused || pressed) && { + ...web({outline: 0}), + textDecorationLine: 'underline', + textDecorationColor: flattenedStyle.color ?? t.palette.primary_500, + }, + flattenedStyle, + ]} + role="link" + onPress={download ? undefined : onPress} onPressIn={onPressIn} onPressOut={onPressOut} onFocus={onFocus} - onBlur={onBlur}> - <Text - label={href} - {...rest} - style={[ - {color: t.palette.primary_500}, - (focused || pressed) && { - outline: 0, - textDecorationLine: 'underline', - textDecorationColor: t.palette.primary_500, - }, - flatten(style), - ]} - role="link" - accessibilityRole="link" - href={href} - {...web({ - hrefAttrs: { - target: isExternal ? 'blank' : undefined, - rel: isExternal ? 'noopener noreferrer' : undefined, - }, - dataSet: stringChildren - ? {} - : { - // default to no underline, apply this ourselves - noUnderline: '1', - }, - })}> - {children} - </Text> - </TouchableWithoutFeedback> + onBlur={onBlur} + onMouseEnter={onHoverIn} + onMouseLeave={onHoverOut} + accessibilityRole="link" + href={href} + {...web({ + hrefAttrs: { + target: download ? undefined : isExternal ? 'blank' : undefined, + rel: isExternal ? 'noopener noreferrer' : undefined, + download, + }, + dataSet: { + // default to no underline, apply this ourselves + noUnderline: '1', + }, + })}> + {children} + </Text> ) } |