Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278import { 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( <HandleHoverCard key={`hc-m-${seg.start}`} handle={mentionHandle} did={did} className="post-body-mention-hover" > <a href={`/profile/${encodeURIComponent(mentionHandle)}`} onClick={onMention?.(mentionHandle)} className="post-body-link post-body-mention" > {matchedText} </a> </HandleHoverCard> ); } else if (f.$type === 'app.bsky.richtext.facet#link') { const uri = (f as { uri: string }).uri; nodes.push( <a key={`l-${seg.start}`} href={uri} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="post-body-link post-body-url" > {matchedText} </a> ); } else if (f.$type === 'app.bsky.richtext.facet#tag') { const tag = (f as { tag: string }).tag; nodes.push( <a key={`t-${seg.start}`} href={`/search?q=${encodeURIComponent('#' + tag)}`} onClick={onHashtag?.(tag)} className="post-body-link post-body-hashtag" > {matchedText} </a> ); }
cursor = seg.end; }
if (cursor < text.length) { nodes.push(text.slice(cursor)); }
return <div className={className}>{nodes}</div>;}
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 <div className={className}>{text}</div>; }
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( <HandleHoverCard key={`hc-m-${i}`} handle={match.capture} className="post-body-mention-hover" > <a href={`/profile/${encodeURIComponent(match.capture)}`} onClick={onMention?.(match.capture)} className="post-body-link post-body-mention" > @{match.capture} </a> </HandleHoverCard> ); } else if (match.type === 'url') { nodes.push( <a key={`l-${i}`} href={match.capture} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="post-body-link post-body-url" > {match.capture} </a> ); } else if (match.type === 'hashtag') { nodes.push( <a key={`t-${i}`} href={`/search?q=${encodeURIComponent('#' + match.capture)}`} onClick={onHashtag?.(match.capture)} className="post-body-link post-body-hashtag" > #{match.capture} </a> ); }
cursor = match.end; }
if (cursor < text.length) { nodes.push(text.slice(cursor)); }
return <div className={className}>{nodes}</div>;}