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