Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 450 lines 12 kB view raw
1import {type JSX, memo, useCallback, useMemo} from 'react' 2import { 3 type GestureResponderEvent, 4 Platform, 5 Pressable, 6 type StyleProp, 7 type TextProps, 8 type TextStyle, 9 type TouchableOpacity, 10 View, 11 type ViewStyle, 12} from 'react-native' 13import {sanitizeUrl} from '@braintree/sanitize-url' 14import {StackActions} from '@react-navigation/native' 15 16import { 17 type DebouncedNavigationProp, 18 useNavigationDeduped, 19} from '#/lib/hooks/useNavigationDeduped' 20import {useOpenLink} from '#/lib/hooks/useOpenLink' 21import {getTabState, TabState} from '#/lib/routes/helpers' 22import { 23 convertBskyAppUrlIfNeeded, 24 isExternalUrl, 25 linkRequiresWarning, 26} from '#/lib/strings/url-helpers' 27import {type TypographyVariant} from '#/lib/ThemeContext' 28import {emitSoftReset} from '#/state/events' 29import {useModalControls} from '#/state/modals' 30import {WebAuxClickWrapper} from '#/view/com/util/WebAuxClickWrapper' 31import {useTheme} from '#/alf' 32import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 33import {IS_ANDROID, IS_WEB} from '#/env' 34import {router} from '../../../routes' 35import {PressableWithHover} from './PressableWithHover' 36import {Text} from './text/Text' 37 38type Event = 39 | React.MouseEvent<HTMLAnchorElement, MouseEvent> 40 | GestureResponderEvent 41 42interface Props extends React.ComponentProps<typeof TouchableOpacity> { 43 testID?: string 44 style?: StyleProp<ViewStyle> 45 href?: string 46 title?: string 47 children?: React.ReactNode 48 hoverStyle?: StyleProp<ViewStyle> 49 noFeedback?: boolean 50 asAnchor?: boolean 51 dataSet?: any 52 anchorNoUnderline?: boolean 53 navigationAction?: 'push' | 'replace' | 'navigate' 54 onPointerEnter?: () => void 55 onPointerLeave?: () => void 56 onBeforePress?: () => void 57} 58 59/** 60 * @deprecated use Link from `#/components/Link.tsx` instead 61 */ 62export const Link = memo(function Link({ 63 testID, 64 style, 65 href, 66 title, 67 children, 68 noFeedback, 69 asAnchor, 70 accessible, 71 anchorNoUnderline, 72 navigationAction, 73 onBeforePress, 74 accessibilityActions, 75 onAccessibilityAction, 76 dataSet: dataSetProp, 77 ...props 78}: Props) { 79 const t = useTheme() 80 const {closeModal} = useModalControls() 81 const navigation = useNavigationDeduped() 82 const anchorHref = asAnchor ? sanitizeUrl(href) : undefined 83 const openLink = useOpenLink() 84 85 const onPress = useCallback( 86 (e?: Event) => { 87 onBeforePress?.() 88 if (typeof href === 'string') { 89 return onPressInner( 90 closeModal, 91 navigation, 92 sanitizeUrl(href), 93 navigationAction, 94 openLink, 95 e, 96 ) 97 } 98 }, 99 [closeModal, navigation, navigationAction, href, openLink, onBeforePress], 100 ) 101 102 const accessibilityActionsWithActivate = [ 103 ...(accessibilityActions || []), 104 {name: 'activate', label: title}, 105 ] 106 107 const dataSet = anchorNoUnderline 108 ? {...dataSetProp, noUnderline: 1} 109 : dataSetProp 110 111 if (noFeedback) { 112 return ( 113 <WebAuxClickWrapper> 114 <Pressable 115 testID={testID} 116 onPress={onPress} 117 accessible={accessible} 118 accessibilityRole="link" 119 accessibilityActions={accessibilityActionsWithActivate} 120 onAccessibilityAction={e => { 121 if (e.nativeEvent.actionName === 'activate') { 122 onPress() 123 } else { 124 onAccessibilityAction?.(e) 125 } 126 }} 127 // @ts-ignore web only -sfn 128 dataSet={dataSet} 129 {...props} 130 android_ripple={{ 131 color: t.atoms.bg_contrast_25.backgroundColor, 132 }} 133 unstable_pressDelay={IS_ANDROID ? 90 : undefined}> 134 {/* @ts-ignore web only -prf */} 135 <View style={style} href={anchorHref}> 136 {children ? children : <Text>{title || 'link'}</Text>} 137 </View> 138 </Pressable> 139 </WebAuxClickWrapper> 140 ) 141 } 142 143 const Com = props.hoverStyle ? PressableWithHover : Pressable 144 return ( 145 <Com 146 testID={testID} 147 style={style} 148 onPress={onPress} 149 accessible={accessible} 150 accessibilityRole="link" 151 accessibilityLabel={props.accessibilityLabel ?? title} 152 accessibilityHint={props.accessibilityHint} 153 // @ts-ignore web only -prf 154 href={anchorHref} 155 dataSet={dataSet} 156 {...props}> 157 {children ? children : <Text>{title || 'link'}</Text>} 158 </Com> 159 ) 160}) 161 162/** 163 * @deprecated use InlineLinkText from `#/components/Link.tsx` instead 164 */ 165export const TextLink = memo(function TextLink({ 166 testID, 167 type = 'md', 168 style, 169 href, 170 text, 171 numberOfLines, 172 lineHeight, 173 dataSet: dataSetProp, 174 title, 175 onPress: onPressProp, 176 onBeforePress, 177 disableMismatchWarning, 178 navigationAction, 179 anchorNoUnderline, 180 ...props 181}: { 182 testID?: string 183 type?: TypographyVariant 184 style?: StyleProp<TextStyle> 185 href: string 186 text: string | JSX.Element | React.ReactNode 187 numberOfLines?: number 188 lineHeight?: number 189 dataSet?: any 190 title?: string 191 disableMismatchWarning?: boolean 192 navigationAction?: 'push' | 'replace' | 'navigate' 193 anchorNoUnderline?: boolean 194 onBeforePress?: () => void 195} & TextProps) { 196 const navigation = useNavigationDeduped() 197 const {closeModal} = useModalControls() 198 const {linkWarningDialogControl} = useGlobalDialogsControlContext() 199 const openLink = useOpenLink() 200 201 if (!disableMismatchWarning && typeof text !== 'string') { 202 console.error('Unable to detect mismatching label') 203 } 204 205 const dataSet = anchorNoUnderline 206 ? {...dataSetProp, noUnderline: 1} 207 : dataSetProp 208 209 const onPress = useCallback( 210 (e?: Event) => { 211 const requiresWarning = 212 !disableMismatchWarning && 213 linkRequiresWarning(href, typeof text === 'string' ? text : '') 214 if (requiresWarning) { 215 e?.preventDefault?.() 216 linkWarningDialogControl.open({ 217 displayText: typeof text === 'string' ? text : '', 218 href, 219 }) 220 } 221 if ( 222 IS_WEB && 223 href !== '#' && 224 e != null && 225 isModifiedEvent(e as React.MouseEvent) 226 ) { 227 // Let the browser handle opening in new tab etc. 228 return 229 } 230 onBeforePress?.() 231 if (onPressProp) { 232 e?.preventDefault?.() 233 // @ts-expect-error function signature differs by platform -prf 234 return onPressProp() 235 } 236 return onPressInner( 237 closeModal, 238 navigation, 239 sanitizeUrl(href), 240 navigationAction, 241 openLink, 242 e, 243 ) 244 }, 245 [ 246 onBeforePress, 247 onPressProp, 248 closeModal, 249 navigation, 250 href, 251 text, 252 disableMismatchWarning, 253 navigationAction, 254 openLink, 255 linkWarningDialogControl, 256 ], 257 ) 258 const hrefAttrs = useMemo(() => { 259 const isExternal = isExternalUrl(href) 260 if (isExternal) { 261 return { 262 target: '_blank', 263 // rel: 'noopener noreferrer', 264 } 265 } 266 return {} 267 }, [href]) 268 269 return ( 270 <Text 271 testID={testID} 272 type={type} 273 style={style} 274 numberOfLines={numberOfLines} 275 lineHeight={lineHeight} 276 dataSet={dataSet} 277 title={title} 278 // @ts-ignore web only -prf 279 hrefAttrs={hrefAttrs} // hack to get open in new tab to work on safari. without this, safari will open in a new window 280 onPress={onPress} 281 accessibilityRole="link" 282 href={convertBskyAppUrlIfNeeded(sanitizeUrl(href))} 283 {...props}> 284 {text} 285 </Text> 286 ) 287}) 288 289/** 290 * Only acts as a link on desktop web 291 */ 292interface TextLinkOnWebOnlyProps extends TextProps { 293 testID?: string 294 type?: TypographyVariant 295 style?: StyleProp<TextStyle> 296 href: string 297 text: string | JSX.Element 298 numberOfLines?: number 299 lineHeight?: number 300 accessible?: boolean 301 accessibilityLabel?: string 302 accessibilityHint?: string 303 title?: string 304 navigationAction?: 'push' | 'replace' | 'navigate' 305 disableMismatchWarning?: boolean 306 onBeforePress?: () => void 307 onPointerEnter?: () => void 308 anchorNoUnderline?: boolean 309} 310/** 311 * @deprecated use WebOnlyInlineLinkText from `#/components/Link.tsx` instead 312 */ 313export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ 314 testID, 315 type = 'md', 316 style, 317 href, 318 text, 319 numberOfLines, 320 lineHeight, 321 navigationAction, 322 disableMismatchWarning, 323 onBeforePress, 324 ...props 325}: TextLinkOnWebOnlyProps) { 326 if (IS_WEB) { 327 return ( 328 <TextLink 329 testID={testID} 330 type={type} 331 style={style} 332 href={href} 333 text={text} 334 numberOfLines={numberOfLines} 335 lineHeight={lineHeight} 336 title={props.title} 337 navigationAction={navigationAction} 338 disableMismatchWarning={disableMismatchWarning} 339 onBeforePress={onBeforePress} 340 {...props} 341 /> 342 ) 343 } 344 return ( 345 <Text 346 testID={testID} 347 type={type} 348 style={style} 349 numberOfLines={numberOfLines} 350 lineHeight={lineHeight} 351 title={props.title} 352 {...props}> 353 {text} 354 </Text> 355 ) 356}) 357 358const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/'] 359 360// NOTE 361// we can't use the onPress given by useLinkProps because it will 362// match most paths to the HomeTab routes while we actually want to 363// preserve the tab the app is currently in 364// 365// we also have some additional behaviors - closing the current modal, 366// converting bsky urls, and opening http/s links in the system browser 367// 368// this method copies from the onPress implementation but adds our 369// needed customizations 370// -prf 371function onPressInner( 372 closeModal = () => {}, 373 navigation: DebouncedNavigationProp, 374 href: string, 375 navigationAction: 'push' | 'replace' | 'navigate' = 'push', 376 openLink: (href: string) => void, 377 e?: Event, 378) { 379 let shouldHandle = false 380 const isLeftClick = 381 // @ts-ignore Web only -prf 382 Platform.OS === 'web' && (e.button == null || e.button === 0) 383 // @ts-ignore Web only -prf 384 const isMiddleClick = Platform.OS === 'web' && e.button === 1 385 const isMetaKey = 386 // @ts-ignore Web only -prf 387 Platform.OS === 'web' && (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) 388 const newTab = isMetaKey || isMiddleClick 389 390 if (Platform.OS !== 'web' || !e) { 391 shouldHandle = e ? !e.defaultPrevented : true 392 } else if ( 393 !e.defaultPrevented && // onPress prevented default 394 (isLeftClick || isMiddleClick) && // ignore everything but left and middle clicks 395 // @ts-ignore Web only -prf 396 [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc. 397 ) { 398 e.preventDefault() 399 shouldHandle = true 400 } 401 402 if (shouldHandle) { 403 href = convertBskyAppUrlIfNeeded(href) 404 if ( 405 newTab || 406 href.startsWith('http') || 407 href.startsWith('mailto') || 408 EXEMPT_PATHS.some(path => href.startsWith(path)) 409 ) { 410 openLink(href) 411 } else { 412 closeModal() // close any active modals 413 414 const [routeName, params] = router.matchPath(href) 415 if (navigationAction === 'push') { 416 // @ts-ignore we're not able to type check on this one -prf 417 navigation.dispatch(StackActions.push(routeName, params)) 418 } else if (navigationAction === 'replace') { 419 // @ts-ignore we're not able to type check on this one -prf 420 navigation.dispatch(StackActions.replace(routeName, params)) 421 } else if (navigationAction === 'navigate') { 422 const state = navigation.getState() 423 const tabState = getTabState(state, routeName) 424 if (tabState === TabState.InsideAtRoot) { 425 emitSoftReset() 426 } else { 427 // note: 'navigate' actually acts the same as 'push' nowadays 428 // therefore we need to add 'pop' -sfn 429 // @ts-ignore we're not able to type check on this one -prf 430 navigation.navigate(routeName, params, {pop: true}) 431 } 432 } else { 433 throw Error('Unsupported navigator action.') 434 } 435 } 436 } 437} 438 439function isModifiedEvent(e: React.MouseEvent): boolean { 440 const eventTarget = e.currentTarget as HTMLAnchorElement 441 const target = eventTarget.getAttribute('target') 442 return ( 443 (target && target !== '_self') || 444 e.metaKey || 445 e.ctrlKey || 446 e.shiftKey || 447 e.altKey || 448 (e.nativeEvent && e.nativeEvent.which === 2) 449 ) 450}