this repo has no description
1import {useCallback, useMemo} from 'react'
2import {type GestureResponderEvent, Linking} from 'react-native'
3import {sanitizeUrl} from '@braintree/sanitize-url'
4import {
5 type LinkProps as RNLinkProps,
6 StackActions,
7} from '@react-navigation/native'
8
9import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
10import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
11import {useOpenLink} from '#/lib/hooks/useOpenLink'
12import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
13import {shareUrl} from '#/lib/sharing'
14import {
15 convertBskyAppUrlIfNeeded,
16 createProxiedUrl,
17 isBskyDownloadUrl,
18 isExternalUrl,
19 linkRequiresWarning,
20} from '#/lib/strings/url-helpers'
21import {useModalControls} from '#/state/modals'
22import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
23import {Button, type ButtonProps} from '#/components/Button'
24import {useInteractionState} from '#/components/hooks/useInteractionState'
25import {Text, type TextProps} from '#/components/Typography'
26import {IS_NATIVE, IS_WEB} from '#/env'
27import {router} from '#/routes'
28import {useGlobalDialogsControlContext} from './dialogs/Context'
29
30/**
31 * Only available within a `Link`, since that inherits from `Button`.
32 * `InlineLink` provides no context.
33 */
34export {useButtonContext as useLinkContext} from '#/components/Button'
35
36type BaseLinkProps = {
37 testID?: string
38
39 to: RNLinkProps<AllNavigatorParams> | string
40
41 /**
42 * The React Navigation `StackAction` to perform when the link is pressed.
43 */
44 action?: 'push' | 'replace' | 'navigate'
45
46 /**
47 * If true, will warn the user if the link text does not match the href.
48 *
49 * Note: atm this only works for `InlineLink`s with a string child.
50 */
51 disableMismatchWarning?: boolean
52
53 /**
54 * Callback for when the link is pressed. Prevent default and return `false`
55 * to exit early and prevent navigation.
56 *
57 * DO NOT use this for navigation, that's what the `to` prop is for.
58 */
59 onPress?: (e: GestureResponderEvent) => void | false
60
61 /**
62 * Callback for when the link is long pressed (on native). Prevent default
63 * and return `false` to exit early and prevent default long press hander.
64 */
65 onLongPress?: (e: GestureResponderEvent) => void | false
66
67 /**
68 * Web-only attribute. Sets `download` attr on web.
69 */
70 download?: string
71
72 /**
73 * Native-only attribute. If true, will open the share sheet on long press.
74 */
75 shareOnLongPress?: boolean
76
77 /**
78 * Whether the link should be opened through the redirect proxy.
79 */
80 shouldProxy?: boolean
81}
82
83export function useLink({
84 to,
85 displayText,
86 action = 'push',
87 disableMismatchWarning,
88 onPress: outerOnPress,
89 onLongPress: outerOnLongPress,
90 shareOnLongPress,
91 overridePresentation,
92 shouldProxy,
93}: BaseLinkProps & {
94 displayText: string
95 overridePresentation?: boolean
96 shouldProxy?: boolean
97}) {
98 const navigation = useNavigationDeduped()
99 const href = useMemo(() => {
100 return typeof to === 'string'
101 ? convertBskyAppUrlIfNeeded(sanitizeUrl(to))
102 : to.screen
103 ? router.matchName(to.screen)?.build(to.params)
104 : to.href
105 ? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
106 : undefined
107 }, [to])
108
109 if (!href) {
110 throw new Error(
111 'Could not resolve screen. Link `to` prop must be a string or an object with `screen` and `params` properties',
112 )
113 }
114
115 const isExternal = isExternalUrl(href)
116 const {closeModal} = useModalControls()
117 const {linkWarningDialogControl} = useGlobalDialogsControlContext()
118 const openLink = useOpenLink()
119
120 const onPress = useCallback(
121 (e: GestureResponderEvent) => {
122 const exitEarlyIfFalse = outerOnPress?.(e)
123
124 if (exitEarlyIfFalse === false) return
125
126 const requiresWarning = Boolean(
127 !disableMismatchWarning &&
128 displayText &&
129 isExternal &&
130 linkRequiresWarning(href, displayText),
131 )
132
133 if (IS_WEB) {
134 e.preventDefault()
135 }
136
137 if (requiresWarning) {
138 linkWarningDialogControl.open({
139 displayText,
140 href,
141 })
142 } else {
143 if (isExternal) {
144 openLink(href, overridePresentation, shouldProxy)
145 } else {
146 const shouldOpenInNewTab = shouldClickOpenNewTab(e)
147
148 if (isBskyDownloadUrl(href)) {
149 shareUrl(BSKY_DOWNLOAD_URL)
150 } else if (
151 shouldOpenInNewTab ||
152 href.startsWith('http') ||
153 href.startsWith('mailto')
154 ) {
155 openLink(href)
156 } else {
157 closeModal() // close any active modals
158
159 const [screen, params] = router.matchPath(href) as [
160 screen: keyof AllNavigatorParams,
161 params?: RouteParams,
162 ]
163
164 // does not apply to web's flat navigator
165 if (IS_NATIVE && screen !== 'NotFound') {
166 const state = navigation.getState()
167 // if screen is not in the current navigator, it means it's
168 // most likely a tab screen. note: state can be undefined
169 if (!state?.routeNames?.includes?.(screen)) {
170 const parent = navigation.getParent()
171 if (
172 parent &&
173 parent.getState().routeNames.includes(`${screen}Tab`)
174 ) {
175 // yep, it's a tab screen. i.e. SearchTab
176 // thus we need to navigate to the child screen
177 // via the parent navigator
178 // see https://reactnavigation.org/docs/upgrading-from-6.x/#changes-to-the-navigate-action
179 // TODO: can we support the other kinds of actions? push/replace -sfn
180
181 // @ts-expect-error include does not narrow the type unfortunately
182 parent.navigate(`${screen}Tab`, {screen, params})
183 return
184 } else {
185 // will probably fail, but let's try anyway
186 }
187 }
188 }
189
190 if (action === 'push') {
191 navigation.dispatch(StackActions.push(screen, params))
192 } else if (action === 'replace') {
193 navigation.dispatch(StackActions.replace(screen, params))
194 } else if (action === 'navigate') {
195 // @ts-expect-error not typed
196 navigation.navigate(screen, params, {pop: true})
197 } else {
198 throw Error('Unsupported navigator action.')
199 }
200 }
201 }
202 }
203 },
204 [
205 outerOnPress,
206 disableMismatchWarning,
207 displayText,
208 isExternal,
209 href,
210 openLink,
211 closeModal,
212 action,
213 navigation,
214 overridePresentation,
215 shouldProxy,
216 linkWarningDialogControl,
217 ],
218 )
219
220 const handleLongPress = useCallback(() => {
221 const requiresWarning = Boolean(
222 !disableMismatchWarning &&
223 displayText &&
224 isExternal &&
225 linkRequiresWarning(href, displayText),
226 )
227
228 if (requiresWarning) {
229 linkWarningDialogControl.open({
230 displayText,
231 href,
232 share: true,
233 })
234 } else {
235 shareUrl(href)
236 }
237 }, [
238 disableMismatchWarning,
239 displayText,
240 href,
241 isExternal,
242 linkWarningDialogControl,
243 ])
244
245 const onLongPress = useCallback(
246 (e: GestureResponderEvent) => {
247 const exitEarlyIfFalse = outerOnLongPress?.(e)
248 if (exitEarlyIfFalse === false) return
249 return IS_NATIVE && shareOnLongPress ? handleLongPress() : undefined
250 },
251 [outerOnLongPress, handleLongPress, shareOnLongPress],
252 )
253
254 return {
255 isExternal,
256 href,
257 onPress,
258 onLongPress,
259 }
260}
261
262export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
263 Omit<ButtonProps, 'onPress' | 'disabled'> & {
264 overridePresentation?: boolean
265 }
266
267/**
268 * A interactive element that renders as a `<a>` tag on the web. On mobile it
269 * will translate the `href` to navigator screens and params and dispatch a
270 * navigation action.
271 *
272 * Intended to behave as a web anchor tag. For more complex routing, use a
273 * `Button`.
274 */
275export function Link({
276 children,
277 to,
278 action = 'push',
279 onPress: outerOnPress,
280 onLongPress: outerOnLongPress,
281 download,
282 shouldProxy,
283 overridePresentation,
284 ...rest
285}: LinkProps) {
286 const {href, isExternal, onPress, onLongPress} = useLink({
287 to,
288 displayText: typeof children === 'string' ? children : '',
289 action,
290 onPress: outerOnPress,
291 onLongPress: outerOnLongPress,
292 shouldProxy: shouldProxy,
293 overridePresentation,
294 })
295
296 return (
297 <Button
298 {...rest}
299 style={[a.justify_start, rest.style]}
300 role="link"
301 accessibilityRole="link"
302 href={href}
303 onPress={download ? undefined : onPress}
304 onLongPress={onLongPress}
305 {...web({
306 hrefAttrs: {
307 target: download ? undefined : isExternal ? 'blank' : undefined,
308 rel: isExternal ? 'noopener noreferrer' : undefined,
309 download,
310 },
311 dataSet: {
312 // no underline, only `InlineLink` has underlines
313 noUnderline: '1',
314 },
315 })}>
316 {children}
317 </Button>
318 )
319}
320
321export type InlineLinkProps = React.PropsWithChildren<
322 BaseLinkProps &
323 TextStyleProp &
324 Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
325 Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
326 disableUnderline?: boolean
327 title?: TextProps['title']
328 overridePresentation?: boolean
329 }
330>
331
332export function InlineLinkText({
333 children,
334 to,
335 action = 'push',
336 disableMismatchWarning,
337 style,
338 onPress: outerOnPress,
339 onLongPress: outerOnLongPress,
340 download,
341 selectable,
342 label,
343 shareOnLongPress,
344 disableUnderline,
345 overridePresentation,
346 shouldProxy,
347 ...rest
348}: InlineLinkProps) {
349 const t = useTheme()
350 const stringChildren = typeof children === 'string'
351 const {href, isExternal, onPress, onLongPress} = useLink({
352 to,
353 displayText: stringChildren ? children : '',
354 action,
355 disableMismatchWarning,
356 onPress: outerOnPress,
357 onLongPress: outerOnLongPress,
358 shareOnLongPress,
359 overridePresentation,
360 shouldProxy: shouldProxy,
361 })
362 const {
363 state: hovered,
364 onIn: onHoverIn,
365 onOut: onHoverOut,
366 } = useInteractionState()
367 const flattenedStyle = flatten(style) || {}
368
369 return (
370 <Text
371 selectable={selectable}
372 accessibilityHint=""
373 accessibilityLabel={label}
374 {...rest}
375 style={[
376 {color: t.palette.primary_500},
377 hovered &&
378 !disableUnderline && {
379 ...web({
380 outline: 0,
381 textDecorationLine: 'underline',
382 textDecorationColor:
383 flattenedStyle.color ?? t.palette.primary_500,
384 }),
385 },
386 flattenedStyle,
387 ]}
388 role="link"
389 onPress={download ? undefined : onPress}
390 onLongPress={onLongPress}
391 onMouseEnter={onHoverIn}
392 onMouseLeave={onHoverOut}
393 accessibilityRole="link"
394 href={href}
395 {...web({
396 hrefAttrs: {
397 target: download ? undefined : isExternal ? 'blank' : undefined,
398 rel: isExternal ? 'noopener noreferrer' : undefined,
399 download,
400 },
401 dataSet: {
402 // default to no underline, apply this ourselves
403 noUnderline: '1',
404 },
405 })}>
406 {children}
407 </Text>
408 )
409}
410
411/**
412 * A barebones version of `InlineLinkText`, for use outside a
413 * `react-navigation` context.
414 */
415export function SimpleInlineLinkText({
416 children,
417 to,
418 style,
419 download,
420 selectable,
421 label,
422 disableUnderline,
423 shouldProxy,
424 onPress: outerOnPress,
425 ...rest
426}: Omit<
427 InlineLinkProps,
428 | 'to'
429 | 'action'
430 | 'disableMismatchWarning'
431 | 'overridePresentation'
432 | 'onLongPress'
433 | 'shareOnLongPress'
434> & {
435 to: string
436}) {
437 const t = useTheme()
438 const {
439 state: hovered,
440 onIn: onHoverIn,
441 onOut: onHoverOut,
442 } = useInteractionState()
443 const flattenedStyle = flatten(style) || {}
444 const isExternal = isExternalUrl(to)
445
446 let href = to
447 if (shouldProxy) {
448 href = createProxiedUrl(href)
449 }
450
451 const onPress = (e: GestureResponderEvent) => {
452 const exitEarlyIfFalse = outerOnPress?.(e)
453 if (exitEarlyIfFalse === false) return
454 Linking.openURL(href)
455 }
456
457 return (
458 <Text
459 selectable={selectable}
460 accessibilityHint=""
461 accessibilityLabel={label}
462 {...rest}
463 style={[
464 {color: t.palette.primary_500},
465 hovered &&
466 !disableUnderline && {
467 ...web({
468 outline: 0,
469 textDecorationLine: 'underline',
470 textDecorationColor:
471 flattenedStyle.color ?? t.palette.primary_500,
472 }),
473 },
474 flattenedStyle,
475 ]}
476 role="link"
477 onPress={onPress}
478 onMouseEnter={onHoverIn}
479 onMouseLeave={onHoverOut}
480 accessibilityRole="link"
481 href={href}
482 {...web({
483 hrefAttrs: {
484 target: download ? undefined : isExternal ? 'blank' : undefined,
485 rel: isExternal ? 'noopener noreferrer' : undefined,
486 download,
487 },
488 dataSet: {
489 // default to no underline, apply this ourselves
490 noUnderline: '1',
491 },
492 })}>
493 {children}
494 </Text>
495 )
496}
497
498export function WebOnlyInlineLinkText({
499 children,
500 to,
501 onPress,
502 ...props
503}: Omit<InlineLinkProps, 'onLongPress'>) {
504 return IS_WEB ? (
505 <InlineLinkText {...props} to={to} onPress={onPress}>
506 {children}
507 </InlineLinkText>
508 ) : (
509 <Text {...props}>{children}</Text>
510 )
511}
512
513/**
514 * Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI
515 *
516 * Example:
517 * `<Link {...createStaticClick(e => {...})} />`
518 */
519export function createStaticClick(
520 onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
521): {
522 to: string
523 onPress: Exclude<BaseLinkProps['onPress'], undefined>
524} {
525 return {
526 to: '#',
527 onPress(e: GestureResponderEvent) {
528 e.preventDefault()
529 onPressHandler(e)
530 return false
531 },
532 }
533}
534
535/**
536 * Utility to create a static `onPress` handler for a `Link`, but only if the
537 * click was not modified in some way e.g. `Cmd` or a middle click.
538 *
539 * On native, this behaves the same as `createStaticClick` because there are no
540 * options to "modify" the click in this sense.
541 *
542 * Example:
543 * `<Link {...createStaticClick(e => {...})} />`
544 */
545export function createStaticClickIfUnmodified(
546 onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
547): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} {
548 return {
549 onPress(e: GestureResponderEvent) {
550 if (!IS_WEB || !isModifiedClickEvent(e)) {
551 e.preventDefault()
552 onPressHandler(e)
553 return false
554 }
555 },
556 }
557}
558
559/**
560 * Determines if the click event has a meta key pressed, indicating the user
561 * intends to deviate from default behavior.
562 */
563export function isClickEventWithMetaKey(e: GestureResponderEvent) {
564 if (!IS_WEB) return false
565 const event = e as unknown as MouseEvent
566 return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey
567}
568
569/**
570 * Determines if the web click target is anything other than `_self`
571 */
572export function isClickTargetExternal(e: GestureResponderEvent) {
573 if (!IS_WEB) return false
574 const event = e as unknown as MouseEvent
575 const el = event.currentTarget as HTMLAnchorElement
576 return el && el.target && el.target !== '_self'
577}
578
579/**
580 * Determines if a click event has been modified in some way from its default
581 * behavior, e.g. `Cmd` or a middle click.
582 * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
583 */
584export function isModifiedClickEvent(e: GestureResponderEvent): boolean {
585 if (!IS_WEB) return false
586 const event = e as unknown as MouseEvent
587 const isPrimaryButton = event.button === 0
588 return (
589 isClickEventWithMetaKey(e) || isClickTargetExternal(e) || !isPrimaryButton
590 )
591}
592
593/**
594 * Determines if a click event has been modified in a way that should indiciate
595 * that the user intends to open a new tab.
596 * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
597 */
598export function shouldClickOpenNewTab(e: GestureResponderEvent) {
599 if (!IS_WEB) return false
600 const event = e as unknown as MouseEvent
601 const isMiddleClick = IS_WEB && event.button === 1
602 return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick
603}