import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import type { Facet, FacetFeature } from '../api/types'; import HandleHoverCard from '../components/HandleHoverCard'; function byteOffsetToStrIndex(text: string, byteOffset: number): number { const encoder = new TextEncoder(); let strIdx = 0; let bytePos = 0; while (bytePos < byteOffset && strIdx < text.length) { const codePoint = text.codePointAt(strIdx)!; const byteLen = encoder.encode(String.fromCodePoint(codePoint)).length; bytePos += byteLen; strIdx += codePoint > 0xffff ? 2 : 1; } return strIdx; } export interface PostBodyProps { text: string; facets?: Facet[]; className?: string; } export default function PostBody({ text, facets, className }: PostBodyProps) { const navigate = useNavigate(); const handleMentionClick = useCallback((did: string) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); navigate(`/profile/${encodeURIComponent(did)}`); }, [navigate]); const handleHashtagClick = useCallback((tag: string) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); navigate(`/search?q=${encodeURIComponent('#' + tag)}`); }, [navigate]); if (facets && facets.length > 0) { return renderWithFacets(text, facets, className, handleMentionClick, handleHashtagClick); } return renderWithRegex(text, className, handleMentionClick, handleHashtagClick); } interface Segment { start: number; end: number; feature: FacetFeature; } function renderWithFacets( text: string, facets: Facet[], className?: string, onMention?: (did: string) => (e: React.MouseEvent) => void, onHashtag?: (tag: string) => (e: React.MouseEvent) => void, ): React.ReactNode { const segments: Segment[] = []; for (const facet of facets) { const start = byteOffsetToStrIndex(text, facet.index.byteStart); const end = byteOffsetToStrIndex(text, facet.index.byteEnd); for (const feature of facet.features) { const t = feature.$type; if ( t === 'app.bsky.richtext.facet#mention' || t === 'app.bsky.richtext.facet#link' || t === 'app.bsky.richtext.facet#tag' ) { segments.push({ start, end, feature }); break; } } } segments.sort((a, b) => a.start - b.start); const nodes: React.ReactNode[] = []; let cursor = 0; for (const seg of segments) { if (seg.start < cursor || seg.end > text.length || seg.start >= seg.end) continue; if (seg.start > cursor) { nodes.push(text.slice(cursor, seg.start)); } const matchedText = text.slice(seg.start, seg.end); const f = seg.feature; if (f.$type === 'app.bsky.richtext.facet#mention') { const did = (f as { did: string }).did; const mentionHandle = matchedText.replace(/^@/, ''); nodes.push( {matchedText} ); } else if (f.$type === 'app.bsky.richtext.facet#link') { const uri = (f as { uri: string }).uri; nodes.push( e.stopPropagation()} className="post-body-link post-body-url" > {matchedText} ); } else if (f.$type === 'app.bsky.richtext.facet#tag') { const tag = (f as { tag: string }).tag; nodes.push( {matchedText} ); } cursor = seg.end; } if (cursor < text.length) { nodes.push(text.slice(cursor)); } return
{nodes}
; } const MENTION_RE = /(^|\s)@([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,})/g; const URL_RE = /(^|\s)(https?:\/\/[^\s<>"{}|\\^`[\]]+)/g; const HASHTAG_RE = /(^|\s)#([a-zA-Z][a-zA-Z0-9_]*)/g; interface RegexMatch { start: number; end: number; type: 'mention' | 'url' | 'hashtag'; capture: string; } function renderWithRegex( text: string, className?: string, onMention?: (did: string) => (e: React.MouseEvent) => void, onHashtag?: (tag: string) => (e: React.MouseEvent) => void, ): React.ReactNode { const matches: RegexMatch[] = []; let m: RegExpExecArray | null; MENTION_RE.lastIndex = 0; while ((m = MENTION_RE.exec(text)) !== null) { matches.push({ start: m.index + m[1].length, end: m.index + m[0].length, type: 'mention', capture: m[2], }); } URL_RE.lastIndex = 0; while ((m = URL_RE.exec(text)) !== null) { matches.push({ start: m.index + m[1].length, end: m.index + m[0].length, type: 'url', capture: m[2], }); } HASHTAG_RE.lastIndex = 0; while ((m = HASHTAG_RE.exec(text)) !== null) { matches.push({ start: m.index + m[1].length, end: m.index + m[0].length, type: 'hashtag', capture: m[2], }); } if (matches.length === 0) { return
{text}
; } matches.sort((a, b) => a.start - b.start); // De-duplicate / resolve overlaps (first match wins) const filtered: RegexMatch[] = []; let lastEnd = 0; for (const match of matches) { if (match.start >= lastEnd) { filtered.push(match); lastEnd = match.end; } } const nodes: React.ReactNode[] = []; let cursor = 0; for (let i = 0; i < filtered.length; i++) { const match = filtered[i]; if (match.start > cursor) { nodes.push(text.slice(cursor, match.start)); } if (match.type === 'mention') { // Use the handle as the navigation target (regex captures handles, not DIDs) nodes.push( @{match.capture} ); } else if (match.type === 'url') { nodes.push( e.stopPropagation()} className="post-body-link post-body-url" > {match.capture} ); } else if (match.type === 'hashtag') { nodes.push( #{match.capture} ); } cursor = match.end; } if (cursor < text.length) { nodes.push(text.slice(cursor)); } return
{nodes}
; }