search and/or read your saved and liked bluesky posts
wails
go
svelte
sqlite
desktop
bluesky
1export type FacetFeature = { [key: string]: any; $type: string };
2
3export type FacetByteSlice = { byteStart: number; byteEnd: number };
4
5export interface Facet {
6 index: FacetByteSlice;
7 features: FacetFeature[];
8}
9
10type FacetKind = "link" | "mention" | "tag" | "text";
11
12export type RenderedFacet = { type: FacetKind; text: string; href?: string; did?: string; tag?: string };
13
14/**
15 * Convert UTF-8 byte offsets to JS string indices (UTF-16 code units)
16 */
17function byteOffsetToCharIndex(text: string, byteOffset: number): number {
18 const encoder = new TextEncoder();
19 let currentByte = 0;
20
21 for (const [i, char] of Array.from(text).entries()) {
22 const charBytes = encoder.encode(char).length;
23
24 if (currentByte >= byteOffset) {
25 return i;
26 }
27
28 currentByte += charBytes;
29 }
30
31 return text.length;
32}
33
34/**
35 * Parse a facets JSON string and return parsed Facet objects
36 */
37export function parseFacets(facetsJson: string): Facet[] {
38 if (!facetsJson) return [];
39
40 try {
41 const parsed = JSON.parse(facetsJson);
42 if (Array.isArray(parsed)) {
43 return parsed as Facet[];
44 }
45 } catch (e) {
46 console.warn("Failed to parse facets:", e);
47 }
48
49 return [];
50}
51
52/**
53 * Render facets into an array of RenderedFacet objects
54 * This converts byte offsets to JS string indices and extracts the text segments
55 */
56export function renderFacets(text: string, facets: Facet[]): RenderedFacet[] {
57 if (!facets || facets.length === 0) {
58 return [{ type: "text", text }];
59 }
60
61 const sortedFacets = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart);
62
63 const result: RenderedFacet[] = [];
64 let lastByteEnd = 0;
65
66 for (const facet of sortedFacets) {
67 if (facet.index.byteStart > lastByteEnd) {
68 const beforeStart = byteOffsetToCharIndex(text, lastByteEnd);
69 const beforeEnd = byteOffsetToCharIndex(text, facet.index.byteStart);
70 const beforeText = text.slice(beforeStart, beforeEnd);
71 if (beforeText) {
72 result.push({ type: "text", text: beforeText });
73 }
74 }
75
76 const facetStart = byteOffsetToCharIndex(text, facet.index.byteStart);
77 const facetEnd = byteOffsetToCharIndex(text, facet.index.byteEnd);
78 const facetText = text.slice(facetStart, facetEnd);
79 let renderedFacet: RenderedFacet = { type: "text", text: facetText };
80
81 for (const feature of facet.features) {
82 const type = feature.$type;
83
84 if (type === "app.bsky.richtext.facet#link") {
85 renderedFacet = { type: "link", text: facetText, href: feature.uri };
86 break;
87 }
88
89 if (type === "app.bsky.richtext.facet#mention") {
90 renderedFacet = {
91 type: "mention",
92 text: facetText,
93 did: feature.did,
94 href: `https://bsky.app/profile/${feature.did}`,
95 };
96 break;
97 }
98
99 if (type === "app.bsky.richtext.facet#tag") {
100 renderedFacet = {
101 type: "tag",
102 text: facetText,
103 tag: feature.tag,
104 href: `https://bsky.app/search?q=%23${encodeURIComponent(feature.tag)}`,
105 };
106 break;
107 }
108 }
109
110 result.push(renderedFacet);
111
112 lastByteEnd = facet.index.byteEnd;
113 }
114
115 const encoder = new TextEncoder();
116 const textBytes = encoder.encode(text).length;
117 if (lastByteEnd < textBytes) {
118 const remainingStart = byteOffsetToCharIndex(text, lastByteEnd);
119 const remainingText = text.slice(remainingStart);
120 if (remainingText) {
121 result.push({ type: "text", text: remainingText });
122 }
123 }
124
125 return result;
126}
127
128/**
129 * Get plain text with facets stripped (for truncation)
130 */
131export function getPlainText(text: string, facets: Facet[]): string {
132 return text;
133}
134
135/**
136 * Truncate rendered facets to a maximum length while preserving facet boundaries
137 */
138export function truncateRenderedFacets(
139 rendered: RenderedFacet[],
140 maxLen: number,
141): { facets: RenderedFacet[]; truncated: boolean } {
142 let currentLength = 0;
143 const result: RenderedFacet[] = [];
144 let truncated = false;
145
146 for (const facet of rendered) {
147 const remaining = maxLen - currentLength;
148
149 if (remaining <= 0) {
150 truncated = true;
151 break;
152 }
153
154 if (facet.text.length <= remaining) {
155 result.push(facet);
156 currentLength += facet.text.length;
157 } else {
158 const truncatedText = facet.text.slice(0, remaining) + "...";
159 result.push({ ...facet, text: truncatedText });
160 truncated = true;
161 break;
162 }
163 }
164
165 return { facets: result, truncated };
166}