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
···
+
import React from "react";
+
import type { AppBskyRichtextFacet } from "@atcute/bluesky";
+
import { createTextSegments, type TextSegment } from "../utils/richtext";
+
+
export interface RichTextProps {
+
text: string;
+
facets?: AppBskyRichtextFacet.Main[];
+
style?: React.CSSProperties;
+
}
+
+
/**
+
* RichText component that renders text with facets (mentions, links, hashtags).
+
* Properly handles byte offsets and multi-byte characters.
+
*/
+
export const RichText: React.FC<RichTextProps> = ({ text, facets, style }) => {
+
const segments = createTextSegments(text, facets);
+
+
return (
+
<span style={style}>
+
{segments.map((segment, idx) => (
+
<RichTextSegment key={idx} segment={segment} />
+
))}
+
</span>
+
);
+
};
+
+
interface RichTextSegmentProps {
+
segment: TextSegment;
+
}
+
+
const RichTextSegment: React.FC<RichTextSegmentProps> = ({ segment }) => {
+
if (!segment.facet) {
+
return <>{segment.text}</>;
+
}
+
+
// Find the first feature in the facet
+
const feature = segment.facet.features?.[0];
+
if (!feature) {
+
return <>{segment.text}</>;
+
}
+
+
const featureType = (feature as { $type?: string }).$type;
+
+
// Render based on feature type
+
switch (featureType) {
+
case "app.bsky.richtext.facet#link": {
+
const linkFeature = feature as AppBskyRichtextFacet.Link;
+
return (
+
<a
+
href={linkFeature.uri}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{
+
color: "var(--atproto-color-link)",
+
textDecoration: "none",
+
}}
+
onMouseEnter={(e) => {
+
e.currentTarget.style.textDecoration = "underline";
+
}}
+
onMouseLeave={(e) => {
+
e.currentTarget.style.textDecoration = "none";
+
}}
+
>
+
{segment.text}
+
</a>
+
);
+
}
+
+
case "app.bsky.richtext.facet#mention": {
+
const mentionFeature = feature as AppBskyRichtextFacet.Mention;
+
const profileUrl = `https://bsky.app/profile/${mentionFeature.did}`;
+
return (
+
<a
+
href={profileUrl}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{
+
color: "var(--atproto-color-link)",
+
textDecoration: "none",
+
}}
+
onMouseEnter={(e) => {
+
e.currentTarget.style.textDecoration = "underline";
+
}}
+
onMouseLeave={(e) => {
+
e.currentTarget.style.textDecoration = "none";
+
}}
+
>
+
{segment.text}
+
</a>
+
);
+
}
+
+
case "app.bsky.richtext.facet#tag": {
+
const tagFeature = feature as AppBskyRichtextFacet.Tag;
+
const tagUrl = `https://bsky.app/hashtag/${encodeURIComponent(tagFeature.tag)}`;
+
return (
+
<a
+
href={tagUrl}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{
+
color: "var(--atproto-color-link)",
+
textDecoration: "none",
+
}}
+
onMouseEnter={(e) => {
+
e.currentTarget.style.textDecoration = "underline";
+
}}
+
onMouseLeave={(e) => {
+
e.currentTarget.style.textDecoration = "none";
+
}}
+
>
+
{segment.text}
+
</a>
+
);
+
}
+
+
default:
+
return <>{segment.text}</>;
+
}
+
};
+
+
export default RichText;
+2 -27
lib/renderers/BlueskyPostRenderer.tsx
···
import { useBlob } from "../hooks/useBlob";
import { BlueskyIcon } from "../components/BlueskyIcon";
import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob";
+
import { RichText } from "../components/RichText";
export interface BlueskyPostRendererProps {
record: FeedPostRecord;
···
}) => (
<div style={baseStyles.body}>
<p style={{ ...baseStyles.text, color: `var(--atproto-color-text)` }}>
-
{text}
+
<RichText text={text} facets={record.facets} />
</p>
-
{record.facets && record.facets.length > 0 && (
-
<div style={baseStyles.facets}>
-
{record.facets.map((_, idx) => (
-
<span
-
key={idx}
-
style={{
-
...baseStyles.facetTag,
-
background: `var(--atproto-color-bg-secondary)`,
-
color: `var(--atproto-color-text-secondary)`,
-
}}
-
>
-
facet
-
</span>
-
))}
-
</div>
-
)}
{resolvedEmbed && (
<div style={baseStyles.embedContainer}>{resolvedEmbed}</div>
)}
···
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
},
-
facets: {
-
marginTop: 8,
-
display: "flex",
-
gap: 4,
-
},
embedContainer: {
marginTop: 12,
padding: 8,
···
inlineIcon: {
display: "inline-flex",
alignItems: "center",
-
},
-
facetTag: {
-
padding: "2px 6px",
-
borderRadius: 4,
-
fontSize: 11,
},
replyLine: {
fontSize: 12,
+120
lib/utils/richtext.ts
···
+
import type { AppBskyRichtextFacet } from "@atcute/bluesky";
+
+
export interface TextSegment {
+
text: string;
+
facet?: AppBskyRichtextFacet.Main;
+
}
+
+
/**
+
* Converts a text string with facets into segments that can be rendered
+
* with appropriate styling and interactivity.
+
*/
+
export function createTextSegments(
+
text: string,
+
facets?: AppBskyRichtextFacet.Main[],
+
): TextSegment[] {
+
if (!facets || facets.length === 0) {
+
return [{ text }];
+
}
+
+
// Build byte-to-char index mapping
+
const bytePrefix = buildBytePrefix(text);
+
+
// Sort facets by start position
+
const sortedFacets = [...facets].sort(
+
(a, b) => a.index.byteStart - b.index.byteStart,
+
);
+
+
const segments: TextSegment[] = [];
+
let currentPos = 0;
+
+
for (const facet of sortedFacets) {
+
const startChar = byteOffsetToCharIndex(bytePrefix, facet.index.byteStart);
+
const endChar = byteOffsetToCharIndex(bytePrefix, facet.index.byteEnd);
+
+
// Add plain text before this facet
+
if (startChar > currentPos) {
+
segments.push({
+
text: sliceByCharRange(text, currentPos, startChar),
+
});
+
}
+
+
// Add the faceted text
+
segments.push({
+
text: sliceByCharRange(text, startChar, endChar),
+
facet,
+
});
+
+
currentPos = endChar;
+
}
+
+
// Add remaining plain text
+
if (currentPos < text.length) {
+
segments.push({
+
text: sliceByCharRange(text, currentPos, text.length),
+
});
+
}
+
+
return segments;
+
}
+
+
/**
+
* Builds a byte offset prefix array for UTF-8 encoded text.
+
* This handles multi-byte characters correctly.
+
*/
+
function buildBytePrefix(text: string): number[] {
+
const encoder = new TextEncoder();
+
const prefix: number[] = [0];
+
let byteCount = 0;
+
+
for (let i = 0; i < text.length; ) {
+
const codePoint = text.codePointAt(i);
+
if (codePoint === undefined) break;
+
+
const char = String.fromCodePoint(codePoint);
+
const encoded = encoder.encode(char);
+
byteCount += encoded.length;
+
prefix.push(byteCount);
+
+
// Handle surrogate pairs (emojis, etc.)
+
i += codePoint > 0xffff ? 2 : 1;
+
}
+
+
return prefix;
+
}
+
+
/**
+
* Converts a byte offset to a character index using the byte prefix array.
+
*/
+
function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number {
+
for (let i = 0; i < prefix.length; i++) {
+
if (prefix[i] === byteOffset) return i;
+
if (prefix[i] > byteOffset) return Math.max(0, i - 1);
+
}
+
return prefix.length - 1;
+
}
+
+
/**
+
* Slices text by character range, handling multi-byte characters correctly.
+
*/
+
function sliceByCharRange(text: string, start: number, end: number): string {
+
if (start <= 0 && end >= text.length) return text;
+
+
let result = "";
+
let charIndex = 0;
+
+
for (let i = 0; i < text.length && charIndex < end; ) {
+
const codePoint = text.codePointAt(i);
+
if (codePoint === undefined) break;
+
+
const char = String.fromCodePoint(codePoint);
+
if (charIndex >= start && charIndex < end) {
+
result += char;
+
}
+
+
i += codePoint > 0xffff ? 2 : 1;
+
charIndex++;
+
}
+
+
return result;
+
}
+19 -1
src/App.tsx
···
showParent={true}
recursiveParent={true}
/>
-
<section />
+
</section>
+
<section style={panelStyle}>
+
<h3 style={sectionHeaderStyle}>
+
Rich Text Facets Demo
+
</h3>
+
<p
+
style={{
+
fontSize: 12,
+
color: `var(--demo-text-secondary)`,
+
margin: "0 0 8px",
+
}}
+
>
+
Post with mentions, links, and hashtags
+
</p>
+
<BlueskyPost
+
did="nekomimi.pet"
+
rkey="3m45s553cys22"
+
showParent={false}
+
/>
</section>
<section style={panelStyle}>
<h3 style={sectionHeaderStyle}>