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