Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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}