+122
lib/components/RichText.tsx
+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
+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
+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
+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}>