Bluesky app fork with some witchin' additions 馃挮
at viewport 282 lines 8.4 kB view raw
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}