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;