Bluesky app fork with some witchin' additions 馃挮
at feat/markdown-basic 215 lines 6.2 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 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}