Files
bsky-social-app/src/components/RichText.tsx
T
Eric Bailey 9aa360d328 Do not render links if uri is invalid (#9663)
* Do not render links if uri is invalid

* Update comment
2026-01-13 09:37:00 -06:00

177 lines
4.7 KiB
TypeScript

import React 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|\()((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
}
export function RichText({
testID,
value,
style,
numberOfLines,
disableLinks,
selectable,
enableTags = false,
authorHandle,
onLinkPress,
interactiveStyle,
emojiMultiplier = 1.85,
onLayout,
onTextLayout,
shouldProxyLinks,
}: RichTextProps) {
const richText = React.useMemo(
() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
[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
emoji
selectable={selectable}
testID={testID}
style={[plainStyles, {fontSize}]}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
</Text>
)
}
return (
<Text
emoji
selectable={selectable}
testID={testID}
style={plainStyles}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
</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 &&
AppBskyRichtextFacet.validateMention(mention).success &&
!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 && AppBskyRichtextFacet.validateLink(link).success) {
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 &&
AppBskyRichtextFacet.validateTag(tag).success
) {
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}
numberOfLines={numberOfLines}
onLayout={onLayout}
onTextLayout={onTextLayout}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{els}
</Text>
)
}