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
18const MARKUP_FACET_TYPE = 'com.example.richtext.facet#markup'
19const MARKUP_STYLES = new Set(['bold', 'italic', 'strikethrough', 'underline'])
20
21type MarkupStyle = 'bold' | 'italic' | 'strikethrough' | 'underline'
22type MarkupFeature = {
23 $type?: string
24 style?: MarkupStyle
25 strip?: [number, number]
26}
27
28function toStripValue(value: unknown) {
29 if (typeof value !== 'number' || !Number.isFinite(value)) {
30 return 0
31 }
32 return Math.max(0, Math.trunc(value))
33}
34
35function stripTextByChars(text: string, stripStart: number, stripEnd: number) {
36 if (!stripStart && !stripEnd) return text
37 const chars = Array.from(text)
38 const safeStart = Math.min(stripStart, chars.length)
39 const safeEnd = Math.min(stripEnd, chars.length - safeStart)
40 return chars.slice(safeStart, chars.length - safeEnd).join('')
41}
42
43function isMarkupFeature(feature: unknown): feature is MarkupFeature {
44 if (!feature || typeof feature !== 'object') return false
45 const candidate = feature as MarkupFeature
46 if (!candidate.style || !MARKUP_STYLES.has(candidate.style)) return false
47 return !candidate.$type || candidate.$type === MARKUP_FACET_TYPE
48}
49
50function getMarkupInfo(text: string, facet?: AppBskyRichtextFacet.Main) {
51 const features = facet?.features ?? []
52 const markupFeatures = features.filter(isMarkupFeature)
53 if (!markupFeatures.length) return undefined
54
55 let stripStart = 0
56 let stripEnd = 0
57 let hasItalic = false
58 let hasBold = false
59 let hasStrikethrough = false
60 let hasUnderline = false
61
62 for (const feature of markupFeatures) {
63 if (feature.strip?.length === 2) {
64 stripStart += toStripValue(feature.strip[0])
65 stripEnd += toStripValue(feature.strip[1])
66 }
67 if (feature.style === 'italic') hasItalic = true
68 if (feature.style === 'bold') hasBold = true
69 if (feature.style === 'strikethrough') hasStrikethrough = true
70 if (feature.style === 'underline') hasUnderline = true
71 }
72
73 const markupStyle: TextStyle = {}
74 if (hasItalic) markupStyle.fontStyle = 'italic'
75 if (hasBold) markupStyle.fontWeight = 'bold'
76 if (hasStrikethrough || hasUnderline) {
77 const decorations = [] as string[]
78 if (hasUnderline) decorations.push('underline')
79 if (hasStrikethrough) decorations.push('line-through')
80 markupStyle.textDecorationLine = decorations.join(' ')
81 }
82
83 return {
84 text: stripTextByChars(text, stripStart, stripEnd),
85 style: markupStyle,
86 }
87}
88
89export type RichTextProps = TextStyleProp &
90 Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
91 value: RichTextAPI | string
92 testID?: string
93 numberOfLines?: number
94 disableLinks?: boolean
95 enableTags?: boolean
96 authorHandle?: string
97 onLinkPress?: LinkProps['onPress']
98 interactiveStyle?: StyleProp<TextStyle>
99 emojiMultiplier?: number
100 shouldProxyLinks?: boolean
101 /**
102 * DANGEROUS: Disable facet lexicon validation
103 *
104 * `detectFacetsWithoutResolution()` generates technically invalid facets,
105 * with a handle in place of the DID. This means that RichText that uses it
106 * won't be able to render links.
107 *
108 * Use with care - only use if you're rendering facets you're generating yourself.
109 */
110 disableMentionFacetValidation?: true
111 }
112
113export function RichText({
114 testID,
115 value,
116 style,
117 numberOfLines,
118 disableLinks,
119 selectable,
120 enableTags = false,
121 authorHandle,
122 onLinkPress,
123 interactiveStyle,
124 emojiMultiplier = 1.85,
125 onLayout,
126 onTextLayout,
127 shouldProxyLinks,
128 disableMentionFacetValidation,
129}: RichTextProps) {
130 const richText = useMemo(() => {
131 if (value instanceof RichTextAPI) {
132 return value
133 } else {
134 const rt = new RichTextAPI({text: value})
135 rt.detectFacetsWithoutResolution()
136 return rt
137 }
138 }, [value])
139
140 const plainStyles = [a.leading_snug, style]
141 const interactiveStyles = [plainStyles, interactiveStyle]
142
143 const {text, facets} = richText
144
145 if (!facets?.length) {
146 if (isOnlyEmoji(text)) {
147 const flattenedStyle = flatten(style) ?? {}
148 const fontSize =
149 (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
150 return (
151 <Text
152 emoji
153 selectable={selectable}
154 testID={testID}
155 style={[plainStyles, {fontSize}]}
156 onLayout={onLayout}
157 onTextLayout={onTextLayout}
158 // @ts-ignore web only -prf
159 dataSet={WORD_WRAP}>
160 {text}
161 </Text>
162 )
163 }
164 return (
165 <Text
166 emoji
167 selectable={selectable}
168 testID={testID}
169 style={plainStyles}
170 numberOfLines={numberOfLines}
171 onLayout={onLayout}
172 onTextLayout={onTextLayout}
173 // @ts-ignore web only -prf
174 dataSet={WORD_WRAP}>
175 {text}
176 </Text>
177 )
178 }
179
180 const els = []
181 let key = 0
182 // N.B. must access segments via `richText.segments`, not via destructuring
183 for (const segment of richText.segments()) {
184 const link = segment.link
185 const mention = segment.mention
186 const tag = segment.tag
187 const markupInfo = getMarkupInfo(segment.text, segment.facet)
188 const segmentText = markupInfo?.text ?? segment.text
189 const markupStyle = markupInfo?.style
190 const interactiveStylesWithMarkup = markupStyle
191 ? [interactiveStyles, markupStyle]
192 : interactiveStyles
193
194 if (
195 mention &&
196 (disableMentionFacetValidation ||
197 AppBskyRichtextFacet.validateMention(mention).success) &&
198 !disableLinks
199 ) {
200 els.push(
201 <ProfileHoverCard key={key} did={mention.did}>
202 <InlineLinkText
203 selectable={selectable}
204 to={`/profile/${mention.did}`}
205 style={interactiveStylesWithMarkup}
206 // @ts-ignore TODO
207 dataSet={WORD_WRAP}
208 shouldProxy={shouldProxyLinks}
209 onPress={onLinkPress}>
210 {segmentText}
211 </InlineLinkText>
212 </ProfileHoverCard>,
213 )
214 } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
215 const isValidLink = URL_REGEX.test(link.uri)
216 if (!isValidLink || disableLinks) {
217 els.push(toShortUrl(segmentText))
218 } else {
219 els.push(
220 <InlineLinkText
221 selectable={selectable}
222 key={key}
223 to={link.uri}
224 style={interactiveStylesWithMarkup}
225 // @ts-ignore TODO
226 dataSet={WORD_WRAP}
227 shareOnLongPress
228 shouldProxy={shouldProxyLinks}
229 onPress={onLinkPress}
230 emoji>
231 {toShortUrl(segmentText)}
232 </InlineLinkText>,
233 )
234 }
235 } else if (
236 !disableLinks &&
237 enableTags &&
238 tag &&
239 AppBskyRichtextFacet.validateTag(tag).success
240 ) {
241 els.push(
242 <RichTextTag
243 key={key}
244 display={segmentText}
245 tag={tag.tag}
246 textStyle={interactiveStylesWithMarkup}
247 authorHandle={authorHandle}
248 />,
249 )
250 } else if (markupStyle) {
251 if (segmentText) {
252 els.push(
253 <Text
254 key={key}
255 selectable={selectable}
256 style={[plainStyles, markupStyle]}
257 emoji>
258 {segmentText}
259 </Text>,
260 )
261 }
262 } else {
263 els.push(segmentText)
264 }
265 key++
266 }
267
268 return (
269 <Text
270 emoji
271 selectable={selectable}
272 testID={testID}
273 style={plainStyles}
274 numberOfLines={numberOfLines}
275 onLayout={onLayout}
276 onTextLayout={onTextLayout}
277 // @ts-ignore web only -prf
278 dataSet={WORD_WRAP}>
279 {els}
280 </Text>
281 )
282}