this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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