tangled
alpha
login
or
join now
nekomimi.pet
/
atproto-ui
41
fork
atom
A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
41
fork
atom
overview
issues
2
pulls
pipelines
facets
waveringana
5 months ago
1d1e582c
f4797892
+263
-28
4 changed files
expand all
collapse all
unified
split
lib
components
RichText.tsx
renderers
BlueskyPostRenderer.tsx
utils
richtext.ts
src
App.tsx
+122
lib/components/RichText.tsx
reviewed
···
1
1
+
import React from "react";
2
2
+
import type { AppBskyRichtextFacet } from "@atcute/bluesky";
3
3
+
import { createTextSegments, type TextSegment } from "../utils/richtext";
4
4
+
5
5
+
export interface RichTextProps {
6
6
+
text: string;
7
7
+
facets?: AppBskyRichtextFacet.Main[];
8
8
+
style?: React.CSSProperties;
9
9
+
}
10
10
+
11
11
+
/**
12
12
+
* RichText component that renders text with facets (mentions, links, hashtags).
13
13
+
* Properly handles byte offsets and multi-byte characters.
14
14
+
*/
15
15
+
export const RichText: React.FC<RichTextProps> = ({ text, facets, style }) => {
16
16
+
const segments = createTextSegments(text, facets);
17
17
+
18
18
+
return (
19
19
+
<span style={style}>
20
20
+
{segments.map((segment, idx) => (
21
21
+
<RichTextSegment key={idx} segment={segment} />
22
22
+
))}
23
23
+
</span>
24
24
+
);
25
25
+
};
26
26
+
27
27
+
interface RichTextSegmentProps {
28
28
+
segment: TextSegment;
29
29
+
}
30
30
+
31
31
+
const RichTextSegment: React.FC<RichTextSegmentProps> = ({ segment }) => {
32
32
+
if (!segment.facet) {
33
33
+
return <>{segment.text}</>;
34
34
+
}
35
35
+
36
36
+
// Find the first feature in the facet
37
37
+
const feature = segment.facet.features?.[0];
38
38
+
if (!feature) {
39
39
+
return <>{segment.text}</>;
40
40
+
}
41
41
+
42
42
+
const featureType = (feature as { $type?: string }).$type;
43
43
+
44
44
+
// Render based on feature type
45
45
+
switch (featureType) {
46
46
+
case "app.bsky.richtext.facet#link": {
47
47
+
const linkFeature = feature as AppBskyRichtextFacet.Link;
48
48
+
return (
49
49
+
<a
50
50
+
href={linkFeature.uri}
51
51
+
target="_blank"
52
52
+
rel="noopener noreferrer"
53
53
+
style={{
54
54
+
color: "var(--atproto-color-link)",
55
55
+
textDecoration: "none",
56
56
+
}}
57
57
+
onMouseEnter={(e) => {
58
58
+
e.currentTarget.style.textDecoration = "underline";
59
59
+
}}
60
60
+
onMouseLeave={(e) => {
61
61
+
e.currentTarget.style.textDecoration = "none";
62
62
+
}}
63
63
+
>
64
64
+
{segment.text}
65
65
+
</a>
66
66
+
);
67
67
+
}
68
68
+
69
69
+
case "app.bsky.richtext.facet#mention": {
70
70
+
const mentionFeature = feature as AppBskyRichtextFacet.Mention;
71
71
+
const profileUrl = `https://bsky.app/profile/${mentionFeature.did}`;
72
72
+
return (
73
73
+
<a
74
74
+
href={profileUrl}
75
75
+
target="_blank"
76
76
+
rel="noopener noreferrer"
77
77
+
style={{
78
78
+
color: "var(--atproto-color-link)",
79
79
+
textDecoration: "none",
80
80
+
}}
81
81
+
onMouseEnter={(e) => {
82
82
+
e.currentTarget.style.textDecoration = "underline";
83
83
+
}}
84
84
+
onMouseLeave={(e) => {
85
85
+
e.currentTarget.style.textDecoration = "none";
86
86
+
}}
87
87
+
>
88
88
+
{segment.text}
89
89
+
</a>
90
90
+
);
91
91
+
}
92
92
+
93
93
+
case "app.bsky.richtext.facet#tag": {
94
94
+
const tagFeature = feature as AppBskyRichtextFacet.Tag;
95
95
+
const tagUrl = `https://bsky.app/hashtag/${encodeURIComponent(tagFeature.tag)}`;
96
96
+
return (
97
97
+
<a
98
98
+
href={tagUrl}
99
99
+
target="_blank"
100
100
+
rel="noopener noreferrer"
101
101
+
style={{
102
102
+
color: "var(--atproto-color-link)",
103
103
+
textDecoration: "none",
104
104
+
}}
105
105
+
onMouseEnter={(e) => {
106
106
+
e.currentTarget.style.textDecoration = "underline";
107
107
+
}}
108
108
+
onMouseLeave={(e) => {
109
109
+
e.currentTarget.style.textDecoration = "none";
110
110
+
}}
111
111
+
>
112
112
+
{segment.text}
113
113
+
</a>
114
114
+
);
115
115
+
}
116
116
+
117
117
+
default:
118
118
+
return <>{segment.text}</>;
119
119
+
}
120
120
+
};
121
121
+
122
122
+
export default RichText;
+2
-27
lib/renderers/BlueskyPostRenderer.tsx
reviewed
···
10
10
import { useBlob } from "../hooks/useBlob";
11
11
import { BlueskyIcon } from "../components/BlueskyIcon";
12
12
import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob";
13
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
239
-
{text}
240
240
+
<RichText text={text} facets={record.facets} />
240
241
</p>
241
241
-
{record.facets && record.facets.length > 0 && (
242
242
-
<div style={baseStyles.facets}>
243
243
-
{record.facets.map((_, idx) => (
244
244
-
<span
245
245
-
key={idx}
246
246
-
style={{
247
247
-
...baseStyles.facetTag,
248
248
-
background: `var(--atproto-color-bg-secondary)`,
249
249
-
color: `var(--atproto-color-text-secondary)`,
250
250
-
}}
251
251
-
>
252
252
-
facet
253
253
-
</span>
254
254
-
))}
255
255
-
</div>
256
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
413
-
facets: {
414
414
-
marginTop: 8,
415
415
-
display: "flex",
416
416
-
gap: 4,
417
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
449
-
},
450
450
-
facetTag: {
451
451
-
padding: "2px 6px",
452
452
-
borderRadius: 4,
453
453
-
fontSize: 11,
454
429
},
455
430
replyLine: {
456
431
fontSize: 12,
+120
lib/utils/richtext.ts
reviewed
···
1
1
+
import type { AppBskyRichtextFacet } from "@atcute/bluesky";
2
2
+
3
3
+
export interface TextSegment {
4
4
+
text: string;
5
5
+
facet?: AppBskyRichtextFacet.Main;
6
6
+
}
7
7
+
8
8
+
/**
9
9
+
* Converts a text string with facets into segments that can be rendered
10
10
+
* with appropriate styling and interactivity.
11
11
+
*/
12
12
+
export function createTextSegments(
13
13
+
text: string,
14
14
+
facets?: AppBskyRichtextFacet.Main[],
15
15
+
): TextSegment[] {
16
16
+
if (!facets || facets.length === 0) {
17
17
+
return [{ text }];
18
18
+
}
19
19
+
20
20
+
// Build byte-to-char index mapping
21
21
+
const bytePrefix = buildBytePrefix(text);
22
22
+
23
23
+
// Sort facets by start position
24
24
+
const sortedFacets = [...facets].sort(
25
25
+
(a, b) => a.index.byteStart - b.index.byteStart,
26
26
+
);
27
27
+
28
28
+
const segments: TextSegment[] = [];
29
29
+
let currentPos = 0;
30
30
+
31
31
+
for (const facet of sortedFacets) {
32
32
+
const startChar = byteOffsetToCharIndex(bytePrefix, facet.index.byteStart);
33
33
+
const endChar = byteOffsetToCharIndex(bytePrefix, facet.index.byteEnd);
34
34
+
35
35
+
// Add plain text before this facet
36
36
+
if (startChar > currentPos) {
37
37
+
segments.push({
38
38
+
text: sliceByCharRange(text, currentPos, startChar),
39
39
+
});
40
40
+
}
41
41
+
42
42
+
// Add the faceted text
43
43
+
segments.push({
44
44
+
text: sliceByCharRange(text, startChar, endChar),
45
45
+
facet,
46
46
+
});
47
47
+
48
48
+
currentPos = endChar;
49
49
+
}
50
50
+
51
51
+
// Add remaining plain text
52
52
+
if (currentPos < text.length) {
53
53
+
segments.push({
54
54
+
text: sliceByCharRange(text, currentPos, text.length),
55
55
+
});
56
56
+
}
57
57
+
58
58
+
return segments;
59
59
+
}
60
60
+
61
61
+
/**
62
62
+
* Builds a byte offset prefix array for UTF-8 encoded text.
63
63
+
* This handles multi-byte characters correctly.
64
64
+
*/
65
65
+
function buildBytePrefix(text: string): number[] {
66
66
+
const encoder = new TextEncoder();
67
67
+
const prefix: number[] = [0];
68
68
+
let byteCount = 0;
69
69
+
70
70
+
for (let i = 0; i < text.length; ) {
71
71
+
const codePoint = text.codePointAt(i);
72
72
+
if (codePoint === undefined) break;
73
73
+
74
74
+
const char = String.fromCodePoint(codePoint);
75
75
+
const encoded = encoder.encode(char);
76
76
+
byteCount += encoded.length;
77
77
+
prefix.push(byteCount);
78
78
+
79
79
+
// Handle surrogate pairs (emojis, etc.)
80
80
+
i += codePoint > 0xffff ? 2 : 1;
81
81
+
}
82
82
+
83
83
+
return prefix;
84
84
+
}
85
85
+
86
86
+
/**
87
87
+
* Converts a byte offset to a character index using the byte prefix array.
88
88
+
*/
89
89
+
function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number {
90
90
+
for (let i = 0; i < prefix.length; i++) {
91
91
+
if (prefix[i] === byteOffset) return i;
92
92
+
if (prefix[i] > byteOffset) return Math.max(0, i - 1);
93
93
+
}
94
94
+
return prefix.length - 1;
95
95
+
}
96
96
+
97
97
+
/**
98
98
+
* Slices text by character range, handling multi-byte characters correctly.
99
99
+
*/
100
100
+
function sliceByCharRange(text: string, start: number, end: number): string {
101
101
+
if (start <= 0 && end >= text.length) return text;
102
102
+
103
103
+
let result = "";
104
104
+
let charIndex = 0;
105
105
+
106
106
+
for (let i = 0; i < text.length && charIndex < end; ) {
107
107
+
const codePoint = text.codePointAt(i);
108
108
+
if (codePoint === undefined) break;
109
109
+
110
110
+
const char = String.fromCodePoint(codePoint);
111
111
+
if (charIndex >= start && charIndex < end) {
112
112
+
result += char;
113
113
+
}
114
114
+
115
115
+
i += codePoint > 0xffff ? 2 : 1;
116
116
+
charIndex++;
117
117
+
}
118
118
+
119
119
+
return result;
120
120
+
}
+19
-1
src/App.tsx
reviewed
···
334
334
showParent={true}
335
335
recursiveParent={true}
336
336
/>
337
337
-
<section />
337
337
+
</section>
338
338
+
<section style={panelStyle}>
339
339
+
<h3 style={sectionHeaderStyle}>
340
340
+
Rich Text Facets Demo
341
341
+
</h3>
342
342
+
<p
343
343
+
style={{
344
344
+
fontSize: 12,
345
345
+
color: `var(--demo-text-secondary)`,
346
346
+
margin: "0 0 8px",
347
347
+
}}
348
348
+
>
349
349
+
Post with mentions, links, and hashtags
350
350
+
</p>
351
351
+
<BlueskyPost
352
352
+
did="nekomimi.pet"
353
353
+
rkey="3m45s553cys22"
354
354
+
showParent={false}
355
355
+
/>
338
356
</section>
339
357
<section style={panelStyle}>
340
358
<h3 style={sectionHeaderStyle}>