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