A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
40
fork

Configure Feed

Select the types of activity you want to include in your feed.

facets

+263 -28
+122
lib/components/RichText.tsx
··· 1 + import React from "react"; 2 + import type { AppBskyRichtextFacet } from "@atcute/bluesky"; 3 + import { createTextSegments, type TextSegment } from "../utils/richtext"; 4 + 5 + export 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 + */ 15 + export 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 + 27 + interface RichTextSegmentProps { 28 + segment: TextSegment; 29 + } 30 + 31 + const 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 + 122 + export default RichText;
+2 -27
lib/renderers/BlueskyPostRenderer.tsx
··· 10 10 import { useBlob } from "../hooks/useBlob"; 11 11 import { BlueskyIcon } from "../components/BlueskyIcon"; 12 12 import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob"; 13 + import { RichText } from "../components/RichText"; 13 14 14 15 export interface BlueskyPostRendererProps { 15 16 record: FeedPostRecord; ··· 236 237 }) => ( 237 238 <div style={baseStyles.body}> 238 239 <p style={{ ...baseStyles.text, color: `var(--atproto-color-text)` }}> 239 - {text} 240 + <RichText text={text} facets={record.facets} /> 240 241 </p> 241 - {record.facets && record.facets.length > 0 && ( 242 - <div style={baseStyles.facets}> 243 - {record.facets.map((_, idx) => ( 244 - <span 245 - key={idx} 246 - style={{ 247 - ...baseStyles.facetTag, 248 - background: `var(--atproto-color-bg-secondary)`, 249 - color: `var(--atproto-color-text-secondary)`, 250 - }} 251 - > 252 - facet 253 - </span> 254 - ))} 255 - </div> 256 - )} 257 242 {resolvedEmbed && ( 258 243 <div style={baseStyles.embedContainer}>{resolvedEmbed}</div> 259 244 )} ··· 410 395 whiteSpace: "pre-wrap", 411 396 overflowWrap: "anywhere", 412 397 }, 413 - facets: { 414 - marginTop: 8, 415 - display: "flex", 416 - gap: 4, 417 - }, 418 398 embedContainer: { 419 399 marginTop: 12, 420 400 padding: 8, ··· 446 426 inlineIcon: { 447 427 display: "inline-flex", 448 428 alignItems: "center", 449 - }, 450 - facetTag: { 451 - padding: "2px 6px", 452 - borderRadius: 4, 453 - fontSize: 11, 454 429 }, 455 430 replyLine: { 456 431 fontSize: 12,
+120
lib/utils/richtext.ts
··· 1 + import type { AppBskyRichtextFacet } from "@atcute/bluesky"; 2 + 3 + export interface TextSegment { 4 + text: string; 5 + facet?: AppBskyRichtextFacet.Main; 6 + } 7 + 8 + /** 9 + * Converts a text string with facets into segments that can be rendered 10 + * with appropriate styling and interactivity. 11 + */ 12 + export function createTextSegments( 13 + text: string, 14 + facets?: AppBskyRichtextFacet.Main[], 15 + ): TextSegment[] { 16 + if (!facets || facets.length === 0) { 17 + return [{ text }]; 18 + } 19 + 20 + // Build byte-to-char index mapping 21 + const bytePrefix = buildBytePrefix(text); 22 + 23 + // Sort facets by start position 24 + const sortedFacets = [...facets].sort( 25 + (a, b) => a.index.byteStart - b.index.byteStart, 26 + ); 27 + 28 + const segments: TextSegment[] = []; 29 + let currentPos = 0; 30 + 31 + for (const facet of sortedFacets) { 32 + const startChar = byteOffsetToCharIndex(bytePrefix, facet.index.byteStart); 33 + const endChar = byteOffsetToCharIndex(bytePrefix, facet.index.byteEnd); 34 + 35 + // Add plain text before this facet 36 + if (startChar > currentPos) { 37 + segments.push({ 38 + text: sliceByCharRange(text, currentPos, startChar), 39 + }); 40 + } 41 + 42 + // Add the faceted text 43 + segments.push({ 44 + text: sliceByCharRange(text, startChar, endChar), 45 + facet, 46 + }); 47 + 48 + currentPos = endChar; 49 + } 50 + 51 + // Add remaining plain text 52 + if (currentPos < text.length) { 53 + segments.push({ 54 + text: sliceByCharRange(text, currentPos, text.length), 55 + }); 56 + } 57 + 58 + return segments; 59 + } 60 + 61 + /** 62 + * Builds a byte offset prefix array for UTF-8 encoded text. 63 + * This handles multi-byte characters correctly. 64 + */ 65 + function buildBytePrefix(text: string): number[] { 66 + const encoder = new TextEncoder(); 67 + const prefix: number[] = [0]; 68 + let byteCount = 0; 69 + 70 + for (let i = 0; i < text.length; ) { 71 + const codePoint = text.codePointAt(i); 72 + if (codePoint === undefined) break; 73 + 74 + const char = String.fromCodePoint(codePoint); 75 + const encoded = encoder.encode(char); 76 + byteCount += encoded.length; 77 + prefix.push(byteCount); 78 + 79 + // Handle surrogate pairs (emojis, etc.) 80 + i += codePoint > 0xffff ? 2 : 1; 81 + } 82 + 83 + return prefix; 84 + } 85 + 86 + /** 87 + * Converts a byte offset to a character index using the byte prefix array. 88 + */ 89 + function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number { 90 + for (let i = 0; i < prefix.length; i++) { 91 + if (prefix[i] === byteOffset) return i; 92 + if (prefix[i] > byteOffset) return Math.max(0, i - 1); 93 + } 94 + return prefix.length - 1; 95 + } 96 + 97 + /** 98 + * Slices text by character range, handling multi-byte characters correctly. 99 + */ 100 + function sliceByCharRange(text: string, start: number, end: number): string { 101 + if (start <= 0 && end >= text.length) return text; 102 + 103 + let result = ""; 104 + let charIndex = 0; 105 + 106 + for (let i = 0; i < text.length && charIndex < end; ) { 107 + const codePoint = text.codePointAt(i); 108 + if (codePoint === undefined) break; 109 + 110 + const char = String.fromCodePoint(codePoint); 111 + if (charIndex >= start && charIndex < end) { 112 + result += char; 113 + } 114 + 115 + i += codePoint > 0xffff ? 2 : 1; 116 + charIndex++; 117 + } 118 + 119 + return result; 120 + }
+19 -1
src/App.tsx
··· 334 334 showParent={true} 335 335 recursiveParent={true} 336 336 /> 337 - <section /> 337 + </section> 338 + <section style={panelStyle}> 339 + <h3 style={sectionHeaderStyle}> 340 + Rich Text Facets Demo 341 + </h3> 342 + <p 343 + style={{ 344 + fontSize: 12, 345 + color: `var(--demo-text-secondary)`, 346 + margin: "0 0 8px", 347 + }} 348 + > 349 + Post with mentions, links, and hashtags 350 + </p> 351 + <BlueskyPost 352 + did="nekomimi.pet" 353 + rkey="3m45s553cys22" 354 + showParent={false} 355 + /> 338 356 </section> 339 357 <section style={panelStyle}> 340 358 <h3 style={sectionHeaderStyle}>