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

facets

Changed files
+263 -28
lib
components
renderers
utils
src
+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}>