forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useMemo} from 'react'
2import {type StyleProp, type TextStyle} from 'react-native'
3import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
4
5import {toShortUrl} from '#/lib/strings/url-helpers'
6import {atoms as a, flatten, type TextStyleProp} from '#/alf'
7import {isOnlyEmoji} from '#/alf/typography'
8import {InlineLinkText, type LinkProps} from '#/components/Link'
9import {ProfileHoverCard} from '#/components/ProfileHoverCard'
10import {RichTextTag} from '#/components/RichTextTag'
11import {Text, type TextProps} from '#/components/Typography'
12
13const WORD_WRAP = {wordWrap: 1}
14// lifted from facet detection in `RichText` impl, _without_ `gm` flags
15const URL_REGEX =
16 /(^|\s|\()(?!javascript:)([a-z][a-z0-9+.-]*:\/\/[\S]+|(?:[a-z0-9]+\.)+[a-z0-9]+(:[0-9]+)?[\S]*|[a-z][a-z0-9+.-]*:[^\s()]+)/i
17
18export type RichTextProps = TextStyleProp &
19 Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
20 value: RichTextAPI | string
21 testID?: string
22 numberOfLines?: number
23 disableLinks?: boolean
24 enableTags?: boolean
25 authorHandle?: string
26 onLinkPress?: LinkProps['onPress']
27 interactiveStyle?: StyleProp<TextStyle>
28 emojiMultiplier?: number
29 shouldProxyLinks?: boolean
30 /**
31 * DANGEROUS: Disable facet lexicon validation
32 *
33 * `detectFacetsWithoutResolution()` generates technically invalid facets,
34 * with a handle in place of the DID. This means that RichText that uses it
35 * won't be able to render links.
36 *
37 * Use with care - only use if you're rendering facets you're generating yourself.
38 */
39 disableMentionFacetValidation?: true
40 }
41
42export function RichText({
43 testID,
44 value,
45 style,
46 numberOfLines,
47 disableLinks,
48 selectable,
49 enableTags = false,
50 authorHandle,
51 onLinkPress,
52 interactiveStyle,
53 emojiMultiplier = 1.85,
54 onLayout,
55 onTextLayout,
56 shouldProxyLinks,
57 disableMentionFacetValidation,
58}: RichTextProps) {
59 const richText = useMemo(() => {
60 if (value instanceof RichTextAPI) {
61 return value
62 } else {
63 const rt = new RichTextAPI({text: value})
64 rt.detectFacetsWithoutResolution()
65 return rt
66 }
67 }, [value])
68
69 const plainStyles = [a.leading_snug, style]
70 const interactiveStyles = [plainStyles, interactiveStyle]
71
72 const {text, facets} = richText
73
74 if (!facets?.length) {
75 if (isOnlyEmoji(text)) {
76 const flattenedStyle = flatten(style) ?? {}
77 const fontSize =
78 (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
79 return (
80 <Text
81 emoji
82 selectable={selectable}
83 testID={testID}
84 style={[plainStyles, {fontSize}]}
85 onLayout={onLayout}
86 onTextLayout={onTextLayout}
87 // @ts-ignore web only -prf
88 dataSet={WORD_WRAP}>
89 {text}
90 </Text>
91 )
92 }
93 return (
94 <Text
95 emoji
96 selectable={selectable}
97 testID={testID}
98 style={plainStyles}
99 numberOfLines={numberOfLines}
100 onLayout={onLayout}
101 onTextLayout={onTextLayout}
102 // @ts-ignore web only -prf
103 dataSet={WORD_WRAP}>
104 {text}
105 </Text>
106 )
107 }
108
109 const els = []
110 let key = 0
111 // N.B. must access segments via `richText.segments`, not via destructuring
112 for (const segment of richText.segments()) {
113 const link = segment.link
114 const mention = segment.mention
115 const tag = segment.tag
116 const features = segment.facet?.features
117 const hasFeature = (kind: string) =>
118 features?.some(
119 f => (f as {$type: string}).$type === `app.bsky.richtext.facet#${kind}`,
120 )
121 const formatStyles = [
122 hasFeature('bold') && a.font_bold,
123 hasFeature('italic') && ({fontStyle: 'italic'} as const),
124 hasFeature('underline') && ({textDecorationLine: 'underline'} as const),
125 ]
126
127 if (
128 mention &&
129 (disableMentionFacetValidation ||
130 AppBskyRichtextFacet.validateMention(mention).success) &&
131 !disableLinks
132 ) {
133 els.push(
134 <ProfileHoverCard key={key} did={mention.did}>
135 <InlineLinkText
136 selectable={selectable}
137 to={`/profile/${mention.did}`}
138 style={[interactiveStyles, ...formatStyles]}
139 // @ts-ignore TODO
140 dataSet={WORD_WRAP}
141 shouldProxy={shouldProxyLinks}
142 onPress={onLinkPress}>
143 {segment.text}
144 </InlineLinkText>
145 </ProfileHoverCard>,
146 )
147 } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
148 const isValidLink = URL_REGEX.test(link.uri)
149 if (!isValidLink || disableLinks) {
150 els.push(toShortUrl(segment.text))
151 } else {
152 els.push(
153 <InlineLinkText
154 selectable={selectable}
155 key={key}
156 to={link.uri}
157 style={[interactiveStyles, ...formatStyles]}
158 // @ts-ignore TODO
159 dataSet={WORD_WRAP}
160 shareOnLongPress
161 shouldProxy={shouldProxyLinks}
162 onPress={onLinkPress}
163 emoji>
164 {toShortUrl(segment.text)}
165 </InlineLinkText>,
166 )
167 }
168 } else if (
169 !disableLinks &&
170 enableTags &&
171 tag &&
172 AppBskyRichtextFacet.validateTag(tag).success
173 ) {
174 els.push(
175 <RichTextTag
176 key={key}
177 display={segment.text}
178 tag={tag.tag}
179 textStyle={
180 [interactiveStyles, ...formatStyles] as StyleProp<TextStyle>
181 }
182 authorHandle={authorHandle}
183 />,
184 )
185 } else if (formatStyles.some(Boolean)) {
186 els.push(
187 <Text
188 key={key}
189 emoji
190 selectable={selectable}
191 style={[plainStyles, ...formatStyles]}>
192 {segment.text}
193 </Text>,
194 )
195 } else {
196 els.push(segment.text)
197 }
198 key++
199 }
200
201 return (
202 <Text
203 emoji
204 selectable={selectable}
205 testID={testID}
206 style={plainStyles}
207 numberOfLines={numberOfLines}
208 onLayout={onLayout}
209 onTextLayout={onTextLayout}
210 // @ts-ignore web only -prf
211 dataSet={WORD_WRAP}>
212 {els}
213 </Text>
214 )
215}