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