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