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