mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {TextStyle} from 'react-native'
3import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
4import {msg} from '@lingui/macro'
5import {useLingui} from '@lingui/react'
6import {useNavigation} from '@react-navigation/native'
7
8import {NavigationProp} from '#/lib/routes/types'
9import {toShortUrl} from '#/lib/strings/url-helpers'
10import {isNative} from '#/platform/detection'
11import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
12import {useInteractionState} from '#/components/hooks/useInteractionState'
13import {InlineLinkText, LinkProps} from '#/components/Link'
14import {ProfileHoverCard} from '#/components/ProfileHoverCard'
15import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
16import {Text, TextProps} from '#/components/Typography'
17
18const WORD_WRAP = {wordWrap: 1}
19
20export function RichText({
21 testID,
22 value,
23 style,
24 numberOfLines,
25 disableLinks,
26 selectable,
27 enableTags = false,
28 authorHandle,
29 onLinkPress,
30 interactiveStyle,
31 emojiMultiplier = 1.85,
32}: TextStyleProp &
33 Pick<TextProps, 'selectable'> & {
34 value: RichTextAPI | string
35 testID?: string
36 numberOfLines?: number
37 disableLinks?: boolean
38 enableTags?: boolean
39 authorHandle?: string
40 onLinkPress?: LinkProps['onPress']
41 interactiveStyle?: TextStyle
42 emojiMultiplier?: number
43 }) {
44 const richText = React.useMemo(
45 () =>
46 value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
47 [value],
48 )
49
50 const flattenedStyle = flatten(style)
51 const plainStyles = [a.leading_snug, flattenedStyle]
52 const interactiveStyles = [
53 a.leading_snug,
54 a.pointer_events_auto,
55 flatten(interactiveStyle),
56 flattenedStyle,
57 ]
58
59 const {text, facets} = richText
60
61 if (!facets?.length) {
62 if (isOnlyEmoji(text)) {
63 const fontSize =
64 (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
65 return (
66 <Text
67 selectable={selectable}
68 testID={testID}
69 style={[plainStyles, {fontSize}]}
70 // @ts-ignore web only -prf
71 dataSet={WORD_WRAP}>
72 {text}
73 </Text>
74 )
75 }
76 return (
77 <Text
78 selectable={selectable}
79 testID={testID}
80 style={plainStyles}
81 numberOfLines={numberOfLines}
82 // @ts-ignore web only -prf
83 dataSet={WORD_WRAP}>
84 {text}
85 </Text>
86 )
87 }
88
89 const els = []
90 let key = 0
91 // N.B. must access segments via `richText.segments`, not via destructuring
92 for (const segment of richText.segments()) {
93 const link = segment.link
94 const mention = segment.mention
95 const tag = segment.tag
96 if (
97 mention &&
98 AppBskyRichtextFacet.validateMention(mention).success &&
99 !disableLinks
100 ) {
101 els.push(
102 <ProfileHoverCard key={key} inline did={mention.did}>
103 <InlineLinkText
104 selectable={selectable}
105 to={`/profile/${mention.did}`}
106 style={interactiveStyles}
107 // @ts-ignore TODO
108 dataSet={WORD_WRAP}
109 onPress={onLinkPress}>
110 {segment.text}
111 </InlineLinkText>
112 </ProfileHoverCard>,
113 )
114 } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
115 if (disableLinks) {
116 els.push(toShortUrl(segment.text))
117 } else {
118 els.push(
119 <InlineLinkText
120 selectable={selectable}
121 key={key}
122 to={link.uri}
123 style={interactiveStyles}
124 // @ts-ignore TODO
125 dataSet={WORD_WRAP}
126 shareOnLongPress
127 onPress={onLinkPress}>
128 {toShortUrl(segment.text)}
129 </InlineLinkText>,
130 )
131 }
132 } else if (
133 !disableLinks &&
134 enableTags &&
135 tag &&
136 AppBskyRichtextFacet.validateTag(tag).success
137 ) {
138 els.push(
139 <RichTextTag
140 key={key}
141 text={segment.text}
142 tag={tag.tag}
143 style={interactiveStyles}
144 selectable={selectable}
145 authorHandle={authorHandle}
146 />,
147 )
148 } else {
149 els.push(segment.text)
150 }
151 key++
152 }
153
154 return (
155 <Text
156 selectable={selectable}
157 testID={testID}
158 style={plainStyles}
159 numberOfLines={numberOfLines}
160 // @ts-ignore web only -prf
161 dataSet={WORD_WRAP}>
162 {els}
163 </Text>
164 )
165}
166
167function RichTextTag({
168 text,
169 tag,
170 style,
171 selectable,
172 authorHandle,
173}: {
174 text: string
175 tag: string
176 selectable?: boolean
177 authorHandle?: string
178} & TextStyleProp) {
179 const t = useTheme()
180 const {_} = useLingui()
181 const control = useTagMenuControl()
182 const {
183 state: hovered,
184 onIn: onHoverIn,
185 onOut: onHoverOut,
186 } = useInteractionState()
187 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
188 const {
189 state: pressed,
190 onIn: onPressIn,
191 onOut: onPressOut,
192 } = useInteractionState()
193 const navigation = useNavigation<NavigationProp>()
194
195 const navigateToPage = React.useCallback(() => {
196 navigation.push('Hashtag', {
197 tag: encodeURIComponent(tag),
198 })
199 }, [navigation, tag])
200
201 const openDialog = React.useCallback(() => {
202 control.open()
203 }, [control])
204
205 /*
206 * N.B. On web, this is wrapped in another pressable comopnent with a11y
207 * labels, etc. That's why only some of these props are applied here.
208 */
209
210 return (
211 <React.Fragment>
212 <TagMenu control={control} tag={tag} authorHandle={authorHandle}>
213 <Text
214 selectable={selectable}
215 {...native({
216 accessibilityLabel: _(msg`Hashtag: #${tag}`),
217 accessibilityHint: _(msg`Long press to open tag menu for #${tag}`),
218 accessibilityRole: isNative ? 'button' : undefined,
219 onPress: navigateToPage,
220 onLongPress: openDialog,
221 onPressIn: onPressIn,
222 onPressOut: onPressOut,
223 })}
224 {...web({
225 onMouseEnter: onHoverIn,
226 onMouseLeave: onHoverOut,
227 })}
228 // @ts-ignore
229 onFocus={onFocus}
230 onBlur={onBlur}
231 style={[
232 web({
233 cursor: 'pointer',
234 }),
235 {color: t.palette.primary_500},
236 (hovered || focused || pressed) && {
237 ...web({outline: 0}),
238 textDecorationLine: 'underline',
239 textDecorationColor: t.palette.primary_500,
240 },
241 style,
242 ]}>
243 {text}
244 </Text>
245 </TagMenu>
246 </React.Fragment>
247 )
248}
249
250export function isOnlyEmoji(text: string) {
251 return (
252 text.length <= 15 &&
253 /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u.test(text)
254 )
255}