A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React from "react"; 2import type { AppBskyRichtextFacet } from "@atcute/bluesky"; 3import { createTextSegments, type TextSegment } from "../utils/richtext"; 4 5export interface RichTextProps { 6 text: string; 7 facets?: AppBskyRichtextFacet.Main[]; 8 style?: React.CSSProperties; 9} 10 11/** 12 * RichText component that renders text with facets (mentions, links, hashtags). 13 * Properly handles byte offsets and multi-byte characters. 14 */ 15export const RichText: React.FC<RichTextProps> = ({ text, facets, style }) => { 16 const segments = createTextSegments(text, facets); 17 18 return ( 19 <span style={style}> 20 {segments.map((segment, idx) => ( 21 <RichTextSegment key={idx} segment={segment} /> 22 ))} 23 </span> 24 ); 25}; 26 27interface RichTextSegmentProps { 28 segment: TextSegment; 29} 30 31const RichTextSegment: React.FC<RichTextSegmentProps> = ({ segment }) => { 32 if (!segment.facet) { 33 return <>{segment.text}</>; 34 } 35 36 // Find the first feature in the facet 37 const feature = segment.facet.features?.[0]; 38 if (!feature) { 39 return <>{segment.text}</>; 40 } 41 42 const featureType = (feature as { $type?: string }).$type; 43 44 // Render based on feature type 45 switch (featureType) { 46 case "app.bsky.richtext.facet#link": { 47 const linkFeature = feature as AppBskyRichtextFacet.Link; 48 return ( 49 <a 50 href={linkFeature.uri} 51 target="_blank" 52 rel="noopener noreferrer" 53 style={{ 54 color: "var(--atproto-color-link)", 55 textDecoration: "none", 56 }} 57 onMouseEnter={(e) => { 58 e.currentTarget.style.textDecoration = "underline"; 59 }} 60 onMouseLeave={(e) => { 61 e.currentTarget.style.textDecoration = "none"; 62 }} 63 > 64 {segment.text} 65 </a> 66 ); 67 } 68 69 case "app.bsky.richtext.facet#mention": { 70 const mentionFeature = feature as AppBskyRichtextFacet.Mention; 71 const profileUrl = `https://bsky.app/profile/${mentionFeature.did}`; 72 return ( 73 <a 74 href={profileUrl} 75 target="_blank" 76 rel="noopener noreferrer" 77 style={{ 78 color: "var(--atproto-color-link)", 79 textDecoration: "none", 80 }} 81 onMouseEnter={(e) => { 82 e.currentTarget.style.textDecoration = "underline"; 83 }} 84 onMouseLeave={(e) => { 85 e.currentTarget.style.textDecoration = "none"; 86 }} 87 > 88 {segment.text} 89 </a> 90 ); 91 } 92 93 case "app.bsky.richtext.facet#tag": { 94 const tagFeature = feature as AppBskyRichtextFacet.Tag; 95 const tagUrl = `https://bsky.app/hashtag/${encodeURIComponent(tagFeature.tag)}`; 96 return ( 97 <a 98 href={tagUrl} 99 target="_blank" 100 rel="noopener noreferrer" 101 style={{ 102 color: "var(--atproto-color-link)", 103 textDecoration: "none", 104 }} 105 onMouseEnter={(e) => { 106 e.currentTarget.style.textDecoration = "underline"; 107 }} 108 onMouseLeave={(e) => { 109 e.currentTarget.style.textDecoration = "none"; 110 }} 111 > 112 {segment.text} 113 </a> 114 ); 115 } 116 117 default: 118 return <>{segment.text}</>; 119 } 120}; 121 122export default RichText;