import {useMemo} from 'react' import {type StyleProp, type TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' import {toShortUrl} from '#/lib/strings/url-helpers' import {atoms as a, flatten, type TextStyleProp} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import {InlineLinkText, type LinkProps} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichTextTag} from '#/components/RichTextTag' import {Text, type TextProps} from '#/components/Typography' const WORD_WRAP = {wordWrap: 1} // lifted from facet detection in `RichText` impl, _without_ `gm` flags const URL_REGEX = /(^|\s|\()(?!javascript:)([a-z][a-z0-9+.-]*:\/\/[\S]+|(?:[a-z0-9]+\.)+[a-z0-9]+(:[0-9]+)?[\S]*|[a-z][a-z0-9+.-]*:[^\s()]+)/i const MARKUP_FACET_TYPE = 'com.example.richtext.facet#markup' const MARKUP_STYLES = new Set(['bold', 'italic', 'strikethrough', 'underline']) type MarkupStyle = 'bold' | 'italic' | 'strikethrough' | 'underline' type MarkupFeature = { $type?: string style?: MarkupStyle strip?: [number, number] } function toStripValue(value: unknown) { if (typeof value !== 'number' || !Number.isFinite(value)) { return 0 } return Math.max(0, Math.trunc(value)) } function stripTextByChars(text: string, stripStart: number, stripEnd: number) { if (!stripStart && !stripEnd) return text const chars = Array.from(text) const safeStart = Math.min(stripStart, chars.length) const safeEnd = Math.min(stripEnd, chars.length - safeStart) return chars.slice(safeStart, chars.length - safeEnd).join('') } function isMarkupFeature(feature: unknown): feature is MarkupFeature { if (!feature || typeof feature !== 'object') return false const candidate = feature as MarkupFeature if (!candidate.style || !MARKUP_STYLES.has(candidate.style)) return false return !candidate.$type || candidate.$type === MARKUP_FACET_TYPE } function getMarkupInfo(text: string, facet?: AppBskyRichtextFacet.Main) { const features = facet?.features ?? [] const markupFeatures = features.filter(isMarkupFeature) if (!markupFeatures.length) return undefined let stripStart = 0 let stripEnd = 0 let hasItalic = false let hasBold = false let hasStrikethrough = false let hasUnderline = false for (const feature of markupFeatures) { if (feature.strip?.length === 2) { stripStart = Math.max(stripStart, toStripValue(feature.strip[0])) stripEnd = Math.max(stripEnd, toStripValue(feature.strip[1])) } if (feature.style === 'italic') hasItalic = true if (feature.style === 'bold') hasBold = true if (feature.style === 'strikethrough') hasStrikethrough = true if (feature.style === 'underline') hasUnderline = true } const markupStyle: TextStyle = {} if (hasItalic) markupStyle.fontStyle = 'italic' if (hasBold) markupStyle.fontWeight = 'bold' if (hasStrikethrough || hasUnderline) { const decorations = [] as string[] if (hasUnderline) decorations.push('underline') if (hasStrikethrough) decorations.push('line-through') markupStyle.textDecorationLine = decorations.join(' ') } return { text: stripTextByChars(text, stripStart, stripEnd), style: markupStyle, } } export type RichTextProps = TextStyleProp & Pick & { value: RichTextAPI | string testID?: string numberOfLines?: number disableLinks?: boolean enableTags?: boolean authorHandle?: string onLinkPress?: LinkProps['onPress'] interactiveStyle?: StyleProp emojiMultiplier?: number shouldProxyLinks?: boolean /** * DANGEROUS: Disable facet lexicon validation * * `detectFacetsWithoutResolution()` generates technically invalid facets, * with a handle in place of the DID. This means that RichText that uses it * won't be able to render links. * * Use with care - only use if you're rendering facets you're generating yourself. */ disableMentionFacetValidation?: true } export function RichText({ testID, value, style, numberOfLines, disableLinks, selectable, enableTags = false, authorHandle, onLinkPress, interactiveStyle, emojiMultiplier = 1.85, onLayout, onTextLayout, shouldProxyLinks, disableMentionFacetValidation, }: RichTextProps) { const richText = useMemo(() => { if (value instanceof RichTextAPI) { return value } else { const rt = new RichTextAPI({text: value}) rt.detectFacetsWithoutResolution() return rt } }, [value]) const plainStyles = [a.leading_snug, style] const interactiveStyles = [plainStyles, interactiveStyle] const {text, facets} = richText if (!facets?.length) { if (isOnlyEmoji(text)) { const flattenedStyle = flatten(style) ?? {} const fontSize = (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier return ( {text} ) } return ( {text} ) } const els = [] let key = 0 // N.B. must access segments via `richText.segments`, not via destructuring for (const segment of richText.segments()) { const link = segment.link const mention = segment.mention const tag = segment.tag const markupInfo = getMarkupInfo(segment.text, segment.facet) const segmentText = markupInfo?.text ?? segment.text const markupStyle = markupInfo?.style const interactiveStylesWithMarkup = markupStyle ? [interactiveStyles, markupStyle] : interactiveStyles if ( mention && (disableMentionFacetValidation || AppBskyRichtextFacet.validateMention(mention).success) && !disableLinks ) { els.push( {segmentText} , ) } else if (link && AppBskyRichtextFacet.validateLink(link).success) { const isValidLink = URL_REGEX.test(link.uri) if (!isValidLink || disableLinks) { els.push(toShortUrl(segmentText)) } else { els.push( {toShortUrl(segmentText)} , ) } } else if ( !disableLinks && enableTags && tag && AppBskyRichtextFacet.validateTag(tag).success ) { els.push( , ) } else if (markupStyle) { if (segmentText) { els.push( {segmentText} , ) } } else { els.push(segmentText) } key++ } return ( {els} ) }