mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {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 {isAndroid, isWeb} from '#/platform/detection'
29import {emitSoftReset} from '#/state/events'
30import {useModalControls} from '#/state/modals'
31import {WebAuxClickWrapper} from '#/view/com/util/WebAuxClickWrapper'
32import {useTheme} from '#/alf'
33import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
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
59export const Link = memo(function Link({
60 testID,
61 style,
62 href,
63 title,
64 children,
65 noFeedback,
66 asAnchor,
67 accessible,
68 anchorNoUnderline,
69 navigationAction,
70 onBeforePress,
71 accessibilityActions,
72 onAccessibilityAction,
73 dataSet: dataSetProp,
74 ...props
75}: Props) {
76 const t = useTheme()
77 const {closeModal} = useModalControls()
78 const navigation = useNavigationDeduped()
79 const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
80 const openLink = useOpenLink()
81
82 const onPress = useCallback(
83 (e?: Event) => {
84 onBeforePress?.()
85 if (typeof href === 'string') {
86 return onPressInner(
87 closeModal,
88 navigation,
89 sanitizeUrl(href),
90 navigationAction,
91 openLink,
92 e,
93 )
94 }
95 },
96 [closeModal, navigation, navigationAction, href, openLink, onBeforePress],
97 )
98
99 const accessibilityActionsWithActivate = [
100 ...(accessibilityActions || []),
101 {name: 'activate', label: title},
102 ]
103
104 const dataSet = useMemo(() => {
105 const ds = {...dataSetProp}
106 if (anchorNoUnderline) {
107 ds.noUnderline = 1
108 }
109 return ds
110 }, [dataSetProp, anchorNoUnderline])
111
112 if (noFeedback) {
113 return (
114 <WebAuxClickWrapper>
115 <Pressable
116 testID={testID}
117 onPress={onPress}
118 accessible={accessible}
119 accessibilityRole="link"
120 accessibilityActions={accessibilityActionsWithActivate}
121 onAccessibilityAction={e => {
122 if (e.nativeEvent.actionName === 'activate') {
123 onPress()
124 } else {
125 onAccessibilityAction?.(e)
126 }
127 }}
128 {...props}
129 android_ripple={{
130 color: t.atoms.bg_contrast_25.backgroundColor,
131 }}
132 unstable_pressDelay={isAndroid ? 90 : undefined}>
133 {/* @ts-ignore web only -prf */}
134 <View style={style} href={anchorHref}>
135 {children ? children : <Text>{title || 'link'}</Text>}
136 </View>
137 </Pressable>
138 </WebAuxClickWrapper>
139 )
140 }
141
142 const Com = props.hoverStyle ? PressableWithHover : Pressable
143 return (
144 <Com
145 testID={testID}
146 style={style}
147 onPress={onPress}
148 accessible={accessible}
149 accessibilityRole="link"
150 accessibilityLabel={props.accessibilityLabel ?? title}
151 accessibilityHint={props.accessibilityHint}
152 // @ts-ignore web only -prf
153 href={anchorHref}
154 dataSet={dataSet}
155 {...props}>
156 {children ? children : <Text>{title || 'link'}</Text>}
157 </Com>
158 )
159})
160
161export const TextLink = memo(function TextLink({
162 testID,
163 type = 'md',
164 style,
165 href,
166 text,
167 numberOfLines,
168 lineHeight,
169 dataSet: dataSetProp,
170 title,
171 onPress: onPressProp,
172 onBeforePress,
173 disableMismatchWarning,
174 navigationAction,
175 anchorNoUnderline,
176 ...props
177}: {
178 testID?: string
179 type?: TypographyVariant
180 style?: StyleProp<TextStyle>
181 href: string
182 text: string | JSX.Element | React.ReactNode
183 numberOfLines?: number
184 lineHeight?: number
185 dataSet?: any
186 title?: string
187 disableMismatchWarning?: boolean
188 navigationAction?: 'push' | 'replace' | 'navigate'
189 anchorNoUnderline?: boolean
190 onBeforePress?: () => void
191} & TextProps) {
192 const navigation = useNavigationDeduped()
193 const {closeModal} = useModalControls()
194 const {linkWarningDialogControl} = useGlobalDialogsControlContext()
195 const openLink = useOpenLink()
196
197 if (!disableMismatchWarning && typeof text !== 'string') {
198 console.error('Unable to detect mismatching label')
199 }
200
201 const dataSet = useMemo(() => {
202 const ds = {...dataSetProp}
203 if (anchorNoUnderline) {
204 ds.noUnderline = 1
205 }
206 return ds
207 }, [dataSetProp, anchorNoUnderline])
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 isWeb &&
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}
310export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
311 testID,
312 type = 'md',
313 style,
314 href,
315 text,
316 numberOfLines,
317 lineHeight,
318 navigationAction,
319 disableMismatchWarning,
320 onBeforePress,
321 ...props
322}: TextLinkOnWebOnlyProps) {
323 if (isWeb) {
324 return (
325 <TextLink
326 testID={testID}
327 type={type}
328 style={style}
329 href={href}
330 text={text}
331 numberOfLines={numberOfLines}
332 lineHeight={lineHeight}
333 title={props.title}
334 navigationAction={navigationAction}
335 disableMismatchWarning={disableMismatchWarning}
336 onBeforePress={onBeforePress}
337 {...props}
338 />
339 )
340 }
341 return (
342 <Text
343 testID={testID}
344 type={type}
345 style={style}
346 numberOfLines={numberOfLines}
347 lineHeight={lineHeight}
348 title={props.title}
349 {...props}>
350 {text}
351 </Text>
352 )
353})
354
355const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/']
356
357// NOTE
358// we can't use the onPress given by useLinkProps because it will
359// match most paths to the HomeTab routes while we actually want to
360// preserve the tab the app is currently in
361//
362// we also have some additional behaviors - closing the current modal,
363// converting bsky urls, and opening http/s links in the system browser
364//
365// this method copies from the onPress implementation but adds our
366// needed customizations
367// -prf
368function onPressInner(
369 closeModal = () => {},
370 navigation: DebouncedNavigationProp,
371 href: string,
372 navigationAction: 'push' | 'replace' | 'navigate' = 'push',
373 openLink: (href: string) => void,
374 e?: Event,
375) {
376 let shouldHandle = false
377 const isLeftClick =
378 // @ts-ignore Web only -prf
379 Platform.OS === 'web' && (e.button == null || e.button === 0)
380 // @ts-ignore Web only -prf
381 const isMiddleClick = Platform.OS === 'web' && e.button === 1
382 const isMetaKey =
383 // @ts-ignore Web only -prf
384 Platform.OS === 'web' && (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
385 const newTab = isMetaKey || isMiddleClick
386
387 if (Platform.OS !== 'web' || !e) {
388 shouldHandle = e ? !e.defaultPrevented : true
389 } else if (
390 !e.defaultPrevented && // onPress prevented default
391 (isLeftClick || isMiddleClick) && // ignore everything but left and middle clicks
392 // @ts-ignore Web only -prf
393 [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc.
394 ) {
395 e.preventDefault()
396 shouldHandle = true
397 }
398
399 if (shouldHandle) {
400 href = convertBskyAppUrlIfNeeded(href)
401 if (
402 newTab ||
403 href.startsWith('http') ||
404 href.startsWith('mailto') ||
405 EXEMPT_PATHS.some(path => href.startsWith(path))
406 ) {
407 openLink(href)
408 } else {
409 closeModal() // close any active modals
410
411 const [routeName, params] = router.matchPath(href)
412 if (navigationAction === 'push') {
413 // @ts-ignore we're not able to type check on this one -prf
414 navigation.dispatch(StackActions.push(routeName, params))
415 } else if (navigationAction === 'replace') {
416 // @ts-ignore we're not able to type check on this one -prf
417 navigation.dispatch(StackActions.replace(routeName, params))
418 } else if (navigationAction === 'navigate') {
419 const state = navigation.getState()
420 const tabState = getTabState(state, routeName)
421 if (tabState === TabState.InsideAtRoot) {
422 emitSoftReset()
423 } else {
424 // @ts-ignore we're not able to type check on this one -prf
425 navigation.navigate(routeName, params)
426 }
427 } else {
428 throw Error('Unsupported navigator action.')
429 }
430 }
431 }
432}
433
434function isModifiedEvent(e: React.MouseEvent): boolean {
435 const eventTarget = e.currentTarget as HTMLAnchorElement
436 const target = eventTarget.getAttribute('target')
437 return (
438 (target && target !== '_self') ||
439 e.metaKey ||
440 e.ctrlKey ||
441 e.shiftKey ||
442 e.altKey ||
443 (e.nativeEvent && e.nativeEvent.which === 2)
444 )
445}