Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}