2d896983ab
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
755 lines
21 KiB
TypeScript
755 lines
21 KiB
TypeScript
import {useCallback, useMemo} from 'react'
|
|
import {
|
|
type GestureResponderEvent,
|
|
Linking,
|
|
type NativeSyntheticEvent,
|
|
type TargetedEvent,
|
|
} from 'react-native'
|
|
import {sanitizeUrl} from '@braintree/sanitize-url'
|
|
import {useLingui} from '@lingui/react/macro'
|
|
import {
|
|
type LinkProps as RNLinkProps,
|
|
StackActions,
|
|
} from '@react-navigation/native'
|
|
|
|
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
|
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
|
|
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
|
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
|
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
|
import {shareUrl} from '#/lib/sharing'
|
|
import {
|
|
convertBskyAppUrlIfNeeded,
|
|
createProxiedUrl,
|
|
getChatInviteCodeFromUrl,
|
|
isBskyDownloadUrl,
|
|
isExternalUrl,
|
|
linkRequiresWarning,
|
|
} from '#/lib/strings/url-helpers'
|
|
import {useModalControls} from '#/state/modals'
|
|
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
|
|
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
|
|
import {Button, type ButtonProps} from '#/components/Button'
|
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
|
import * as PeekMenu from '#/components/PeekMenu'
|
|
import {Text, type TextProps} from '#/components/Typography'
|
|
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
|
import {router} from '#/routes'
|
|
import {useGlobalDialogsControlContext} from './dialogs/Context'
|
|
|
|
/**
|
|
* Only available within a `Link`, since that inherits from `Button`.
|
|
* `InlineLink` provides no context.
|
|
*/
|
|
export {useButtonContext as useLinkContext} from '#/components/Button'
|
|
|
|
type BaseLinkProps = {
|
|
testID?: string
|
|
|
|
to: RNLinkProps<AllNavigatorParams> | string
|
|
|
|
/**
|
|
* The React Navigation `StackAction` to perform when the link is pressed.
|
|
*/
|
|
action?: 'push' | 'replace' | 'navigate'
|
|
|
|
/**
|
|
* If true, will warn the user if the link text does not match the href.
|
|
*
|
|
* Note: atm this only works for `InlineLink`s with a string child.
|
|
*/
|
|
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
|
|
|
|
/**
|
|
* 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
|
|
|
|
/**
|
|
* Native-only attribute. If true, will open the share sheet on long press.
|
|
*/
|
|
shareOnLongPress?: boolean
|
|
|
|
/**
|
|
* Whether the link should be opened through the redirect proxy.
|
|
*/
|
|
shouldProxy?: boolean
|
|
|
|
/**
|
|
* iOS only. Wraps the link in a peek menu: long-pressing previews the live
|
|
* page in an in-app browser (morphing into it on tap when the in-app browser
|
|
* preference is on), with a share action in the menu. No-op elsewhere.
|
|
*/
|
|
peek?: boolean
|
|
|
|
/**
|
|
* Web only
|
|
*/
|
|
onMouseEnter?: () => void
|
|
/**
|
|
* Web only
|
|
*/
|
|
onMouseLeave?: () => void
|
|
}
|
|
|
|
export function useLink({
|
|
to,
|
|
displayText,
|
|
action = 'push',
|
|
disableMismatchWarning,
|
|
onPress: outerOnPress,
|
|
onLongPress: outerOnLongPress,
|
|
shareOnLongPress,
|
|
overridePresentation,
|
|
shouldProxy,
|
|
}: BaseLinkProps & {
|
|
displayText: string
|
|
overridePresentation?: boolean
|
|
shouldProxy?: boolean
|
|
}) {
|
|
const navigation = useNavigationDeduped()
|
|
const href = useMemo(() => {
|
|
return typeof to === 'string'
|
|
? convertBskyAppUrlIfNeeded(sanitizeUrl(to))
|
|
: to.screen
|
|
? router.matchName(to.screen)?.build(to.params)
|
|
: to.href
|
|
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
|
|
: undefined
|
|
}, [to])
|
|
|
|
if (!href) {
|
|
throw new Error(
|
|
'Could not resolve screen. Link `to` prop must be a string or an object with `screen` and `params` properties',
|
|
)
|
|
}
|
|
|
|
const isExternal = isExternalUrl(href)
|
|
const {closeModal} = useModalControls()
|
|
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
|
const openLink = useOpenLink()
|
|
const groupChatJoinIntent = useGroupChatJoinIntent()
|
|
|
|
const onPress = useCallback(
|
|
(e: GestureResponderEvent) => {
|
|
const exitEarlyIfFalse = outerOnPress?.(e)
|
|
|
|
if (exitEarlyIfFalse === false) return
|
|
|
|
const requiresWarning = Boolean(
|
|
!disableMismatchWarning &&
|
|
displayText &&
|
|
isExternal &&
|
|
linkRequiresWarning(href, displayText),
|
|
)
|
|
|
|
if (IS_WEB) {
|
|
e.preventDefault()
|
|
}
|
|
|
|
const chatInviteCode = getChatInviteCodeFromUrl(href)
|
|
if (chatInviteCode) {
|
|
groupChatJoinIntent(chatInviteCode, href)
|
|
return
|
|
}
|
|
|
|
if (requiresWarning) {
|
|
linkWarningDialogControl.open({
|
|
displayText,
|
|
href,
|
|
})
|
|
} else {
|
|
if (isExternal) {
|
|
void openLink(href, overridePresentation, shouldProxy)
|
|
} else {
|
|
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
|
|
|
if (isBskyDownloadUrl(href)) {
|
|
void shareUrl(BSKY_DOWNLOAD_URL)
|
|
} else if (
|
|
shouldOpenInNewTab ||
|
|
href.startsWith('http') ||
|
|
href.startsWith('mailto')
|
|
) {
|
|
void openLink(href)
|
|
} else {
|
|
closeModal() // close any active modals
|
|
|
|
const [screen, params] = router.matchPath(href) as [
|
|
screen: keyof AllNavigatorParams,
|
|
params?: RouteParams,
|
|
]
|
|
|
|
// does not apply to web's flat navigator
|
|
if (IS_NATIVE && screen !== 'NotFound') {
|
|
const state = navigation.getState()
|
|
// if screen is not in the current navigator, it means it's
|
|
// most likely a tab screen. note: state can be undefined
|
|
if (!state?.routeNames?.includes?.(screen)) {
|
|
const parent = navigation.getParent()
|
|
if (
|
|
parent &&
|
|
parent.getState().routeNames.includes(`${screen}Tab`)
|
|
) {
|
|
// yep, it's a tab screen. i.e. SearchTab
|
|
// thus we need to navigate to the child screen
|
|
// via the parent navigator
|
|
// see https://reactnavigation.org/docs/upgrading-from-6.x/#changes-to-the-navigate-action
|
|
// TODO: can we support the other kinds of actions? push/replace -sfn
|
|
|
|
// @ts-expect-error include does not narrow the type unfortunately
|
|
parent.navigate(`${screen}Tab`, {screen, params})
|
|
return
|
|
} else {
|
|
// will probably fail, but let's try anyway
|
|
}
|
|
}
|
|
}
|
|
|
|
if (action === 'push') {
|
|
navigation.dispatch(StackActions.push(screen, params))
|
|
} else if (action === 'replace') {
|
|
navigation.dispatch(StackActions.replace(screen, params))
|
|
} else if (action === 'navigate') {
|
|
// @ts-expect-error not typed
|
|
navigation.navigate(screen, params, {pop: true})
|
|
} else {
|
|
throw Error('Unsupported navigator action.')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[
|
|
outerOnPress,
|
|
disableMismatchWarning,
|
|
displayText,
|
|
isExternal,
|
|
href,
|
|
openLink,
|
|
closeModal,
|
|
action,
|
|
navigation,
|
|
overridePresentation,
|
|
shouldProxy,
|
|
linkWarningDialogControl,
|
|
groupChatJoinIntent,
|
|
],
|
|
)
|
|
|
|
const handleLongPress = useCallback(() => {
|
|
const requiresWarning = Boolean(
|
|
!disableMismatchWarning &&
|
|
displayText &&
|
|
isExternal &&
|
|
linkRequiresWarning(href, displayText),
|
|
)
|
|
|
|
if (requiresWarning) {
|
|
linkWarningDialogControl.open({
|
|
displayText,
|
|
href,
|
|
share: true,
|
|
})
|
|
} else {
|
|
void shareUrl(href)
|
|
}
|
|
}, [
|
|
disableMismatchWarning,
|
|
displayText,
|
|
href,
|
|
isExternal,
|
|
linkWarningDialogControl,
|
|
])
|
|
|
|
const onLongPress = useCallback(
|
|
(e: GestureResponderEvent) => {
|
|
const exitEarlyIfFalse = outerOnLongPress?.(e)
|
|
if (exitEarlyIfFalse === false) return
|
|
return IS_NATIVE && shareOnLongPress ? handleLongPress() : undefined
|
|
},
|
|
[outerOnLongPress, handleLongPress, shareOnLongPress],
|
|
)
|
|
|
|
// Opens the link through the normal external flow (consent dialog, in-app
|
|
// browser, or system browser per preference). Used by the peek menu when the
|
|
// in-app browser is off, so committing the peek behaves like a plain tap.
|
|
const openExternally = useCallback(() => {
|
|
void openLink(href, overridePresentation, shouldProxy)
|
|
}, [openLink, href, overridePresentation, shouldProxy])
|
|
|
|
return {
|
|
isExternal,
|
|
href,
|
|
onPress,
|
|
onLongPress,
|
|
openExternally,
|
|
}
|
|
}
|
|
|
|
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
|
|
Omit<ButtonProps, 'onPress' | 'disabled'> & {
|
|
overridePresentation?: boolean
|
|
}
|
|
|
|
/**
|
|
* A interactive element that renders as a `<a>` tag on the web. On mobile it
|
|
* will translate the `href` to navigator screens and params and dispatch a
|
|
* navigation action.
|
|
*
|
|
* Intended to behave as a web anchor tag. For more complex routing, use a
|
|
* `Button`.
|
|
*/
|
|
export function Link({
|
|
children,
|
|
to,
|
|
action = 'push',
|
|
onPress: outerOnPress,
|
|
onLongPress: outerOnLongPress,
|
|
download,
|
|
shouldProxy,
|
|
overridePresentation,
|
|
peek,
|
|
...rest
|
|
}: LinkProps) {
|
|
const {href, isExternal, onPress, onLongPress, openExternally} = useLink({
|
|
to,
|
|
displayText: typeof children === 'string' ? children : '',
|
|
action,
|
|
onPress: outerOnPress,
|
|
onLongPress: outerOnLongPress,
|
|
shouldProxy: shouldProxy,
|
|
overridePresentation,
|
|
})
|
|
|
|
// Peek is iOS-only and only makes sense for external web links.
|
|
const peekEnabled = Boolean(peek && IS_IOS && isExternal)
|
|
|
|
const button = (
|
|
<Button
|
|
{...rest}
|
|
style={[a.justify_start, rest.style]}
|
|
role="link"
|
|
accessibilityRole="link"
|
|
href={href}
|
|
onPress={download ? undefined : onPress}
|
|
// When peeking, the native long-press drives the context menu. A
|
|
// simultaneous RN long-press recognizer fights it — cancelling the tap
|
|
// and breaking the lift animation — so leave long-press to native and
|
|
// surface sharing through the peek menu instead.
|
|
onLongPress={peekEnabled ? undefined : onLongPress}
|
|
{...web({
|
|
hrefAttrs: {
|
|
target: download ? undefined : isExternal ? 'blank' : undefined,
|
|
rel: isExternal ? 'noopener noreferrer' : undefined,
|
|
download,
|
|
},
|
|
dataSet: {
|
|
// no underline, only `InlineLink` has underlines
|
|
noUnderline: '1',
|
|
},
|
|
})}>
|
|
{children}
|
|
</Button>
|
|
)
|
|
|
|
if (peekEnabled) {
|
|
// Match the lift radius to whatever the consumer styled the link with, so
|
|
// the peek animation clips to the same corners as the rendered card.
|
|
const borderRadius = flatten(rest.style)?.borderRadius
|
|
return (
|
|
<LinkPeek
|
|
href={href}
|
|
onPreviewPress={openExternally}
|
|
shouldProxy={shouldProxy}
|
|
borderRadius={typeof borderRadius === 'number' ? borderRadius : 0}>
|
|
{button}
|
|
</LinkPeek>
|
|
)
|
|
}
|
|
|
|
return button
|
|
}
|
|
|
|
/**
|
|
* iOS peek-menu wrapper for an external `Link`. Long-pressing previews the live
|
|
* page in an in-app browser; the in-app-browser preference decides whether
|
|
* tapping the peek morphs into the browser or hands off to the normal link flow
|
|
* via `onPreviewPress`. The menu carries a Share action.
|
|
*/
|
|
function LinkPeek({
|
|
href,
|
|
onPreviewPress,
|
|
shouldProxy,
|
|
borderRadius,
|
|
children,
|
|
}: {
|
|
href: string
|
|
onPreviewPress: () => void
|
|
shouldProxy?: boolean
|
|
borderRadius: number
|
|
children: React.ReactNode
|
|
}) {
|
|
const t = useTheme()
|
|
const {t: l} = useLingui()
|
|
const useInAppBrowserPref = useInAppBrowser()
|
|
|
|
return (
|
|
<PeekMenu.Root>
|
|
<PeekMenu.Trigger
|
|
preview={{
|
|
type: 'link',
|
|
url: shouldProxy ? createProxiedUrl(href) : href,
|
|
// Only morph natively when the user has explicitly opted in. When the
|
|
// preference is unset (undefined), defer to the JS flow so the consent
|
|
// dialog can show.
|
|
useInAppBrowser: useInAppBrowserPref === true,
|
|
browserToolbarColor: t.atoms.bg.backgroundColor,
|
|
browserControlsColor: t.palette.primary_500,
|
|
}}
|
|
borderRadius={borderRadius}
|
|
// Fires only when not morphing natively (in-app browser off/unset).
|
|
onPreviewPress={onPreviewPress}>
|
|
{children}
|
|
</PeekMenu.Trigger>
|
|
<PeekMenu.Menu>
|
|
<PeekMenu.MenuItem id="share" onSelect={() => void shareUrl(href)}>
|
|
<PeekMenu.MenuItemIcon icon={ShareIcon} />
|
|
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
|
|
</PeekMenu.MenuItem>
|
|
</PeekMenu.Menu>
|
|
</PeekMenu.Root>
|
|
)
|
|
}
|
|
|
|
export type InlineLinkProps = React.PropsWithChildren<
|
|
BaseLinkProps &
|
|
TextStyleProp &
|
|
Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
|
|
Pick<ButtonProps, 'label' | 'accessibilityHint' | 'onFocus' | 'onBlur'> & {
|
|
disableUnderline?: boolean
|
|
title?: TextProps['title']
|
|
overridePresentation?: boolean
|
|
}
|
|
>
|
|
|
|
export function InlineLinkText({
|
|
children,
|
|
to,
|
|
action = 'push',
|
|
disableMismatchWarning,
|
|
style,
|
|
onPress: outerOnPress,
|
|
onLongPress: outerOnLongPress,
|
|
download,
|
|
selectable,
|
|
label,
|
|
shareOnLongPress,
|
|
disableUnderline,
|
|
overridePresentation,
|
|
shouldProxy,
|
|
...rest
|
|
}: InlineLinkProps) {
|
|
const t = useTheme()
|
|
const stringChildren = typeof children === 'string'
|
|
const {href, isExternal, onPress, onLongPress} = useLink({
|
|
to,
|
|
displayText: stringChildren ? children : '',
|
|
action,
|
|
disableMismatchWarning,
|
|
onPress: outerOnPress,
|
|
onLongPress: outerOnLongPress,
|
|
shareOnLongPress,
|
|
overridePresentation,
|
|
shouldProxy: shouldProxy,
|
|
})
|
|
const {
|
|
state: interacted,
|
|
onIn: onInteract,
|
|
onOut: onInteractOut,
|
|
} = useInteractionState()
|
|
const flattenedStyle = flatten(style) || {}
|
|
|
|
return (
|
|
<Text
|
|
selectable={selectable}
|
|
accessibilityHint=""
|
|
accessibilityLabel={label}
|
|
{...rest}
|
|
style={[
|
|
{color: t.palette.primary_500},
|
|
interacted &&
|
|
!disableUnderline && {
|
|
...web({
|
|
outline: 0,
|
|
textDecorationLine: 'underline',
|
|
textDecorationColor:
|
|
flattenedStyle.color ?? t.palette.primary_500,
|
|
}),
|
|
},
|
|
flattenedStyle,
|
|
]}
|
|
role="link"
|
|
onPress={download ? undefined : onPress}
|
|
onLongPress={onLongPress}
|
|
{...web({
|
|
onMouseEnter: () => {
|
|
rest.onMouseEnter?.()
|
|
onInteract()
|
|
},
|
|
onMouseLeave: () => {
|
|
rest.onMouseLeave?.()
|
|
onInteractOut()
|
|
},
|
|
})}
|
|
onFocus={(e: NativeSyntheticEvent<TargetedEvent>) => {
|
|
rest.onFocus?.(e)
|
|
onInteract()
|
|
}}
|
|
onBlur={(e: NativeSyntheticEvent<TargetedEvent>) => {
|
|
rest.onBlur?.(e)
|
|
onInteractOut()
|
|
}}
|
|
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>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A barebones version of `InlineLinkText`, for use outside a
|
|
* `react-navigation` context.
|
|
*/
|
|
export function SimpleInlineLinkText({
|
|
children,
|
|
to,
|
|
style,
|
|
download,
|
|
selectable,
|
|
label,
|
|
disableUnderline,
|
|
shouldProxy,
|
|
onPress: outerOnPress,
|
|
...rest
|
|
}: Omit<
|
|
InlineLinkProps,
|
|
| 'to'
|
|
| 'action'
|
|
| 'disableMismatchWarning'
|
|
| 'overridePresentation'
|
|
| 'onLongPress'
|
|
| 'shareOnLongPress'
|
|
> & {
|
|
to: string
|
|
}) {
|
|
const t = useTheme()
|
|
const {
|
|
state: interacted,
|
|
onIn: onInteract,
|
|
onOut: onInteractOut,
|
|
} = useInteractionState()
|
|
const flattenedStyle = flatten(style) || {}
|
|
const isExternal = isExternalUrl(to)
|
|
|
|
let href = to
|
|
if (shouldProxy) {
|
|
href = createProxiedUrl(href)
|
|
}
|
|
|
|
const onPress = (e: GestureResponderEvent) => {
|
|
const exitEarlyIfFalse = outerOnPress?.(e)
|
|
if (exitEarlyIfFalse === false) return
|
|
void Linking.openURL(href)
|
|
}
|
|
|
|
return (
|
|
<Text
|
|
selectable={selectable}
|
|
accessibilityHint=""
|
|
accessibilityLabel={label}
|
|
{...rest}
|
|
style={[
|
|
{color: t.palette.primary_500},
|
|
interacted &&
|
|
!disableUnderline && {
|
|
...web({
|
|
outline: 0,
|
|
textDecorationLine: 'underline',
|
|
textDecorationColor:
|
|
flattenedStyle.color ?? t.palette.primary_500,
|
|
}),
|
|
},
|
|
flattenedStyle,
|
|
]}
|
|
role="link"
|
|
onPress={onPress}
|
|
{...web({
|
|
onMouseEnter: () => {
|
|
rest.onMouseEnter?.()
|
|
onInteract()
|
|
},
|
|
onMouseLeave: () => {
|
|
rest.onMouseLeave?.()
|
|
onInteractOut()
|
|
},
|
|
})}
|
|
onFocus={(e: NativeSyntheticEvent<TargetedEvent>) => {
|
|
rest.onFocus?.(e)
|
|
onInteract()
|
|
}}
|
|
onBlur={(e: NativeSyntheticEvent<TargetedEvent>) => {
|
|
rest.onBlur?.(e)
|
|
onInteractOut()
|
|
}}
|
|
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>
|
|
)
|
|
}
|
|
|
|
export function WebOnlyInlineLinkText({
|
|
children,
|
|
to,
|
|
onPress,
|
|
...props
|
|
}: Omit<InlineLinkProps, 'onLongPress'>) {
|
|
return IS_WEB ? (
|
|
<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
|
|
*
|
|
* Example:
|
|
* `<Link {...createStaticClick(e => {...})} />`
|
|
*/
|
|
export function createStaticClick(
|
|
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
|
|
): {
|
|
to: string
|
|
onPress: Exclude<BaseLinkProps['onPress'], undefined>
|
|
} {
|
|
return {
|
|
to: '#',
|
|
onPress(e: GestureResponderEvent) {
|
|
e.preventDefault()
|
|
onPressHandler(e)
|
|
return false
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (!IS_WEB || !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 (!IS_WEB) 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 (!IS_WEB) 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 (!IS_WEB) 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 (!IS_WEB) return false
|
|
const event = e as unknown as MouseEvent
|
|
const isMiddleClick = IS_WEB && event.button === 1
|
|
return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick
|
|
}
|