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