Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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