mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at samuel/exp-cli 515 lines 14 kB view raw
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}