mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {
3 GestureResponderEvent,
4 Pressable,
5 StyleProp,
6 ViewStyle,
7} from 'react-native'
8import {sanitizeUrl} from '@braintree/sanitize-url'
9import {StackActions, useLinkProps} from '@react-navigation/native'
10
11import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
12import {AllNavigatorParams} from '#/lib/routes/types'
13import {shareUrl} from '#/lib/sharing'
14import {
15 convertBskyAppUrlIfNeeded,
16 isBskyDownloadUrl,
17 isExternalUrl,
18 linkRequiresWarning,
19} from '#/lib/strings/url-helpers'
20import {isNative} from '#/platform/detection'
21import {shouldClickOpenNewTab} from '#/platform/urls'
22import {useModalControls} from '#/state/modals'
23import {useOpenLink} from '#/state/preferences/in-app-browser'
24import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
25import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
26import {Button, ButtonProps} from '#/components/Button'
27import {useInteractionState} from '#/components/hooks/useInteractionState'
28import {Text, TextProps} from '#/components/Typography'
29import {router} from '#/routes'
30
31/**
32 * Only available within a `Link`, since that inherits from `Button`.
33 * `InlineLink` provides no context.
34 */
35export {useButtonContext as useLinkContext} from '#/components/Button'
36
37type BaseLinkProps = Pick<
38 Parameters<typeof useLinkProps<AllNavigatorParams>>[0],
39 'to'
40> & {
41 testID?: string
42
43 /**
44 * The React Navigation `StackAction` to perform when the link is pressed.
45 */
46 action?: 'push' | 'replace' | 'navigate'
47
48 /**
49 * If true, will warn the user if the link text does not match the href.
50 *
51 * Note: atm this only works for `InlineLink`s with a string child.
52 */
53 disableMismatchWarning?: boolean
54
55 /**
56 * Callback for when the link is pressed. Prevent default and return `false`
57 * to exit early and prevent navigation.
58 *
59 * DO NOT use this for navigation, that's what the `to` prop is for.
60 */
61 onPress?: (e: GestureResponderEvent) => void | false
62
63 /**
64 * Web-only attribute. Sets `download` attr on web.
65 */
66 download?: string
67
68 /**
69 * Native-only attribute. If true, will open the share sheet on long press.
70 */
71 shareOnLongPress?: boolean
72}
73
74export function useLink({
75 to,
76 displayText,
77 action = 'push',
78 disableMismatchWarning,
79 onPress: outerOnPress,
80 shareOnLongPress,
81}: BaseLinkProps & {
82 displayText: string
83}) {
84 const navigation = useNavigationDeduped()
85 const {href} = useLinkProps<AllNavigatorParams>({
86 to:
87 typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
88 })
89 const isExternal = isExternalUrl(href)
90 const {openModal, closeModal} = useModalControls()
91 const openLink = useOpenLink()
92
93 const onPress = React.useCallback(
94 (e: GestureResponderEvent) => {
95 const exitEarlyIfFalse = outerOnPress?.(e)
96
97 if (exitEarlyIfFalse === false) return
98
99 const requiresWarning = Boolean(
100 !disableMismatchWarning &&
101 displayText &&
102 isExternal &&
103 linkRequiresWarning(href, displayText),
104 )
105
106 if (requiresWarning) {
107 e.preventDefault()
108
109 openModal({
110 name: 'link-warning',
111 text: displayText,
112 href: href,
113 })
114 } else {
115 e.preventDefault()
116
117 if (isExternal) {
118 openLink(href)
119 } else {
120 const shouldOpenInNewTab = shouldClickOpenNewTab(e)
121
122 if (isBskyDownloadUrl(href)) {
123 shareUrl(BSKY_DOWNLOAD_URL)
124 } else if (
125 shouldOpenInNewTab ||
126 href.startsWith('http') ||
127 href.startsWith('mailto')
128 ) {
129 openLink(href)
130 } else {
131 closeModal() // close any active modals
132
133 if (action === 'push') {
134 navigation.dispatch(StackActions.push(...router.matchPath(href)))
135 } else if (action === 'replace') {
136 navigation.dispatch(
137 StackActions.replace(...router.matchPath(href)),
138 )
139 } else if (action === 'navigate') {
140 // @ts-ignore
141 navigation.navigate(...router.matchPath(href))
142 } else {
143 throw Error('Unsupported navigator action.')
144 }
145 }
146 }
147 }
148 },
149 [
150 outerOnPress,
151 disableMismatchWarning,
152 displayText,
153 isExternal,
154 href,
155 openModal,
156 openLink,
157 closeModal,
158 action,
159 navigation,
160 ],
161 )
162
163 const handleLongPress = React.useCallback(() => {
164 const requiresWarning = Boolean(
165 !disableMismatchWarning &&
166 displayText &&
167 isExternal &&
168 linkRequiresWarning(href, displayText),
169 )
170
171 if (requiresWarning) {
172 openModal({
173 name: 'link-warning',
174 text: displayText,
175 href: href,
176 share: true,
177 })
178 } else {
179 shareUrl(href)
180 }
181 }, [disableMismatchWarning, displayText, href, isExternal, openModal])
182
183 const onLongPress =
184 isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
185
186 return {
187 isExternal,
188 href,
189 onPress,
190 onLongPress,
191 }
192}
193
194export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
195 Omit<ButtonProps, 'onPress' | 'disabled'>
196
197/**
198 * A interactive element that renders as a `<a>` tag on the web. On mobile it
199 * will translate the `href` to navigator screens and params and dispatch a
200 * navigation action.
201 *
202 * Intended to behave as a web anchor tag. For more complex routing, use a
203 * `Button`.
204 */
205export function Link({
206 children,
207 to,
208 action = 'push',
209 onPress: outerOnPress,
210 download,
211 ...rest
212}: LinkProps) {
213 const {href, isExternal, onPress} = useLink({
214 to,
215 displayText: typeof children === 'string' ? children : '',
216 action,
217 onPress: outerOnPress,
218 })
219
220 return (
221 <Button
222 {...rest}
223 style={[a.justify_start, flatten(rest.style)]}
224 role="link"
225 accessibilityRole="link"
226 href={href}
227 onPress={download ? undefined : onPress}
228 {...web({
229 hrefAttrs: {
230 target: download ? undefined : isExternal ? 'blank' : undefined,
231 rel: isExternal ? 'noopener noreferrer' : undefined,
232 download,
233 },
234 dataSet: {
235 // no underline, only `InlineLink` has underlines
236 noUnderline: '1',
237 },
238 })}>
239 {children}
240 </Button>
241 )
242}
243
244export type InlineLinkProps = React.PropsWithChildren<
245 BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
246> &
247 Pick<ButtonProps, 'label'>
248
249export function InlineLinkText({
250 children,
251 to,
252 action = 'push',
253 disableMismatchWarning,
254 style,
255 onPress: outerOnPress,
256 download,
257 selectable,
258 label,
259 shareOnLongPress,
260 ...rest
261}: InlineLinkProps) {
262 const t = useTheme()
263 const stringChildren = typeof children === 'string'
264 const {href, isExternal, onPress, onLongPress} = useLink({
265 to,
266 displayText: stringChildren ? children : '',
267 action,
268 disableMismatchWarning,
269 onPress: outerOnPress,
270 shareOnLongPress,
271 })
272 const {
273 state: hovered,
274 onIn: onHoverIn,
275 onOut: onHoverOut,
276 } = useInteractionState()
277 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
278 const {
279 state: pressed,
280 onIn: onPressIn,
281 onOut: onPressOut,
282 } = useInteractionState()
283 const flattenedStyle = flatten(style) || {}
284
285 return (
286 <Text
287 selectable={selectable}
288 accessibilityHint=""
289 accessibilityLabel={label}
290 {...rest}
291 style={[
292 {color: t.palette.primary_500},
293 (hovered || focused || pressed) && {
294 ...web({outline: 0}),
295 textDecorationLine: 'underline',
296 textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
297 },
298 flattenedStyle,
299 ]}
300 role="link"
301 onPress={download ? undefined : onPress}
302 onLongPress={onLongPress}
303 onPressIn={onPressIn}
304 onPressOut={onPressOut}
305 onFocus={onFocus}
306 onBlur={onBlur}
307 onMouseEnter={onHoverIn}
308 onMouseLeave={onHoverOut}
309 accessibilityRole="link"
310 href={href}
311 {...web({
312 hrefAttrs: {
313 target: download ? undefined : isExternal ? 'blank' : undefined,
314 rel: isExternal ? 'noopener noreferrer' : undefined,
315 download,
316 },
317 dataSet: {
318 // default to no underline, apply this ourselves
319 noUnderline: '1',
320 },
321 })}>
322 {children}
323 </Text>
324 )
325}
326
327/**
328 * A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
329 * in Link are not desired.
330 * @param displayText
331 * @param style
332 * @param children
333 * @param rest
334 * @constructor
335 */
336export function BaseLink({
337 displayText,
338 onPress: onPressOuter,
339 style,
340 children,
341 ...rest
342}: {
343 style?: StyleProp<ViewStyle>
344 children: React.ReactNode
345 to: string
346 action: 'push' | 'replace' | 'navigate'
347 onPress?: () => false | void
348 shareOnLongPress?: boolean
349 label: string
350 displayText?: string
351}) {
352 const {onPress, ...btnProps} = useLink({
353 displayText: displayText ?? rest.to,
354 ...rest,
355 })
356 return (
357 <Pressable
358 style={style}
359 onPress={e => {
360 onPressOuter?.()
361 onPress(e)
362 }}
363 {...btnProps}>
364 {children}
365 </Pressable>
366 )
367}