import {useMemo} from 'react' import {type StyleProp, type TextStyle} from 'react-native' import {RichText as RichTextAPI} from '@bsky/sdk/richtext' import {isRTLText} from '#/lib/strings/text-direction' import {toShortUrl} from '#/lib/strings/url-helpers' import {android, 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' import {IS_NATIVE} from '#/env' import {app} from '#/lexicons' import * as bsky from '#/types/bsky' const WORD_WRAP = {wordWrap: 1} // lifted from facet detection in `RichText` impl, _without_ `gm` flags const URL_REGEX = /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/i 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 suffix?: React.ReactNode /** * How far below the text baseline `suffix` extends, in px. * * Android clips inline views that are translated below the measured text * bounds. Reserve matching room there and cancel it with a negative margin * so content following the text does not move. iOS allows inline attachment * overflow through `RNUITextView` and does not need this compensation. * * Overrides any `paddingBottom`/`marginBottom` set via `style` on Android. */ suffixOffset?: number /** * 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, suffix, suffixOffset = 0, disableMentionFacetValidation, }: RichTextProps) { const richText = useMemo(() => { if (value instanceof RichTextAPI) { return value } else { const rt = new RichTextAPI({text: value}) rt.detectFacetsWithoutResolution() return rt } }, [value]) const {text, facets} = richText const plainStyles: StyleProp = [ style, IS_NATIVE && isRTLText(text) ? {textAlign: 'right'} : null, ] const suffixStyles = suffix && suffixOffset ? android({paddingBottom: suffixOffset, marginBottom: -suffixOffset}) : null const interactiveStyles = [plainStyles, interactiveStyle] if (!facets?.length) { if (isOnlyEmoji(text)) { const flattenedStyle = flatten(style) const fontSize = (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier return ( {text} {suffix ? ' ' : null} {suffix} ) } return ( {text} {suffix ? ' ' : null} {suffix} ) } 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 if ( mention && (disableMentionFacetValidation || bsky.matches(app.bsky.richtext.facet.mention, mention)) && !disableLinks ) { els.push( {segment.text} , ) } else if (link && bsky.matches(app.bsky.richtext.facet.link, link)) { const isValidLink = URL_REGEX.test(link.uri) if (!isValidLink || disableLinks) { els.push(toShortUrl(segment.text)) } else { els.push( {toShortUrl(segment.text)} , ) } } else if ( !disableLinks && enableTags && tag && bsky.matches(app.bsky.richtext.facet.tag, tag) ) { els.push( , ) } else { els.push(segment.text) } key++ } return ( {els} {suffix ? ' ' : null} {suffix} ) }