import type { ReactNode } from 'react';
import type { Handle } from '@atcute/lexicons';
import { clsx } from 'clsx';
import { parseRichtext, type Richtext, segmentizeRichtext, toPlainText } from '#/lib/rich-text';
import { profileTarget } from '#/lib/routes/targets';
import { isOnlyEmoji } from '#/lib/utils/emoji';
import { parseLinkableUrl, toShortUrl } from '#/lib/utils/url';
import { ProfileHoverCard } from '#/components/ProfileHoverCard';
import { atomicSegment, emoji } from '#/components/RichText.css';
import { RichTextTag } from '#/components/RichTextTag';
import { Text, type TextProps } from '#/components/Text';
import { ContentLinkText, InlineLinkText, type InlineLinkUnderline } from '#/components/web/Link';
export type RichTextProps = Pick<
TextProps,
'align' | 'color' | 'leading' | 'numberOfLines' | 'selectable' | 'weight'
> & {
authorHandle?: Handle;
className?: string;
/**
* Render mentions as plain links without their profile hover card — set when RichText is itself inside a
* hover card, to stop cards cascading.
*/
disableHoverCards?: boolean;
disableLinks?: boolean;
/** Enlargement applied to emoji-only content: `normal` (1.85×) or `large` (3×). */
emojiScale?: 'large' | 'normal';
enableTags?: boolean;
/** Underline timing for inline links, mentions, and tags; defaults to `hover`. */
linkUnderline?: InlineLinkUnderline;
/** The body-text sizes RichText renders (a subset of the full scale), each enlarged for emoji-only content. */
size?: 'lg' | 'md' | 'md_sub' | 'sm' | 'xl' | 'xs';
/** content appended inline and included in line clamping. */
suffix?: ReactNode;
value: Richtext | string;
};
/**
* renders an atproto richtext value (or a plain string with on-the-fly facet detection) as inline text with
* mentions, links, and hashtags. mentions and links inherit the text color (defaulting to the link color);
* emoji-only content is enlarged based on {@link RichTextProps.emojiScale}.
*/
export function RichText({
align,
authorHandle,
className,
color,
disableHoverCards,
disableLinks,
emojiScale = 'normal',
enableTags = false,
leading,
linkUnderline,
numberOfLines,
selectable,
size,
suffix,
value,
weight,
}: RichTextProps) {
const segments = typeof value === 'string' ? parseRichtext(value) : segmentizeRichtext(value);
let text: string | undefined;
if (segments.every((segment) => segment.type === 'text')) {
text = toPlainText(segments);
}
const tail = suffix ? <> {suffix}</> : null;
// emoji-only text is enlarged and unclamped, so it takes its own host rather than the shared one below
if (text !== undefined && isOnlyEmoji(text)) {
return (
<Text
align={align}
className={clsx(emoji({ scale: emojiScale, size }), className)}
color={color}
leading={leading}
selectable={selectable}
weight={weight}
>
{text}
{tail}
</Text>
);
}
let children: ReactNode = text;
if (text === undefined) {
const els: ReactNode[] = [];
let key = 0;
for (const segment of segments) {
let el: ReactNode = segment.text;
switch (segment.type) {
case 'link': {
// reject malformed and non-http(s) facet URIs
if (disableLinks || parseLinkableUrl(segment.uri) == null) {
el = toShortUrl(segment.text);
} else {
el = (
<ContentLinkText
color={color}
href={segment.uri}
key={key}
leading={leading}
selectable={selectable}
size={size}
underline={linkUnderline}
weight={weight}
>
{toShortUrl(segment.text)}
</ContentLinkText>
);
}
break;
}
case 'mention':
case 'unresolvedMention': {
const actor = segment.type === 'mention' ? segment.did : segment.handle;
if (!disableLinks) {
const link = (
<InlineLinkText
className={atomicSegment}
color={color}
key={key}
leading={leading}
selectable={selectable}
size={size}
to={profileTarget(actor)}
underline={linkUnderline}
weight={weight}
>
{segment.text}
</InlineLinkText>
);
el = disableHoverCards ? (
link
) : (
<ProfileHoverCard actor={actor} key={key}>
{link}
</ProfileHoverCard>
);
}
break;
}
case 'tag': {
if (!disableLinks && enableTags) {
el = (
<RichTextTag
authorHandle={authorHandle}
color={color}
display={segment.text}
key={key}
leading={leading}
size={size}
tag={segment.tag}
underline={linkUnderline}
/>
);
}
break;
}
}
els.push(el);
key++;
}
children = els;
}
return (
<Text
align={align}
className={className}
color={color}
leading={leading}
numberOfLines={numberOfLines}
selectable={selectable}
size={size}
weight={weight}
>
{children}
{tail}
</Text>
);
}