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