Files
bsky-social-app/src/components/RichText.tsx
T
2026-08-13 12:26:23 -07:00

220 lines
6.2 KiB
TypeScript

import {useMemo} from 'react'
import {type StyleProp, type TextStyle} from 'react-native'
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
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 {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]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/i
export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
disableLinks?: boolean
enableTags?: boolean
authorHandle?: string
onLinkPress?: LinkProps['onPress']
interactiveStyle?: StyleProp<TextStyle>
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 plainStyles = style
const suffixStyles =
suffix && suffixOffset
? android({paddingBottom: suffixOffset, marginBottom: -suffixOffset})
: null
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
emoji
selectable={selectable}
testID={testID}
style={[plainStyles, {fontSize}, suffixStyles]}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
{suffix ? ' ' : null}
{suffix}
</Text>
)
}
return (
<Text
emoji
selectable={selectable}
testID={testID}
style={[plainStyles, suffixStyles]}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
{suffix ? ' ' : null}
{suffix}
</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
if (
mention &&
(disableMentionFacetValidation ||
bsky.matches(app.bsky.richtext.facet.mention, mention)) &&
!disableLinks
) {
els.push(
<ProfileHoverCard key={key} did={mention.did}>
<InlineLinkText
selectable={selectable}
to={`/profile/${mention.did}`}
style={interactiveStyles}
// @ts-ignore TODO
dataSet={WORD_WRAP}
shouldProxy={shouldProxyLinks}
onPress={onLinkPress}>
{segment.text}
</InlineLinkText>
</ProfileHoverCard>,
)
} 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(
<InlineLinkText
selectable={selectable}
key={key}
to={link.uri}
style={interactiveStyles}
// @ts-ignore TODO
dataSet={WORD_WRAP}
shareOnLongPress
shouldProxy={shouldProxyLinks}
onPress={onLinkPress}
emoji>
{toShortUrl(segment.text)}
</InlineLinkText>,
)
}
} else if (
!disableLinks &&
enableTags &&
tag &&
bsky.matches(app.bsky.richtext.facet.tag, tag)
) {
els.push(
<RichTextTag
key={key}
display={segment.text}
tag={tag.tag}
textStyle={interactiveStyles}
authorHandle={authorHandle}
/>,
)
} else {
els.push(segment.text)
}
key++
}
return (
<Text
emoji
selectable={selectable}
testID={testID}
style={[plainStyles, suffixStyles]}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{els}
{suffix ? ' ' : null}
{suffix}
</Text>
)
}