search and/or read your saved and liked bluesky posts
wails go svelte sqlite desktop bluesky
at main 166 lines 4.5 kB view raw
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}