Bluesky app fork with some witchin' additions 馃挮
at main 17 kB view raw
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}