an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
at main 255 lines 6.0 kB view raw
1import type { $Typed,Facet } from "@atproto/api"; 2import * as React from "react"; 3 4export const CACHE_TIMEOUT = 5 * 60 * 1000; 5const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 6 7export function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 8 return obj as $Typed<T>; 9} 10 11export const fullDateTimeFormat = (iso: string) => { 12 const date = new Date(iso); 13 return date.toLocaleString("en-US", { 14 month: "long", 15 day: "numeric", 16 year: "numeric", 17 hour: "numeric", 18 minute: "2-digit", 19 hour12: true, 20 }); 21}; 22 23export const shortTimeAgo = (iso: string) => { 24 const diff = Date.now() - new Date(iso).getTime(); 25 const mins = Math.floor(diff / 60000); 26 if (mins < 1) return "now"; 27 if (mins < 60) return `${mins}m`; 28 const hrs = Math.floor(mins / 60); 29 if (hrs < 24) return `${hrs}h`; 30 const days = Math.floor(hrs / 24); 31 return `${days}d`; 32}; 33 34export function getByteToCharMap(text: string): number[] { 35 const encoder = new TextEncoder(); 36 37 const map: number[] = []; 38 let byteIndex = 0; 39 let charIndex = 0; 40 41 for (const char of text) { 42 const bytes = encoder.encode(char); 43 for (let i = 0; i < bytes.length; i++) { 44 map[byteIndex++] = charIndex; 45 } 46 charIndex += char.length; 47 } 48 49 return map; 50} 51 52export function facetByteRangeToCharRange( 53 byteStart: number, 54 byteEnd: number, 55 byteToCharMap: number[], 56): [number, number] { 57 return [ 58 byteToCharMap[byteStart] ?? 0, 59 byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end 60 ]; 61} 62 63interface FacetRange { 64 start: number; 65 end: number; 66 feature: Facet["features"][number]; 67} 68 69export function extractFacetRanges( 70 text: string, 71 facets: Facet[], 72): FacetRange[] { 73 const map = getByteToCharMap(text); 74 return facets.map((f) => { 75 const [start, end] = facetByteRangeToCharRange( 76 f.index.byteStart, 77 f.index.byteEnd, 78 map, 79 ); 80 return { start, end, feature: f.features[0] }; 81 }); 82} 83 84export function renderTextWithFacets({ 85 text, 86 facets, 87 navigate, 88}: { 89 text: string; 90 facets: Facet[]; 91 navigate: (_: any) => void; 92}) { 93 const ranges = extractFacetRanges(text, facets).sort( 94 (a: any, b: any) => a.start - b.start, 95 ); 96 97 const result: React.ReactNode[] = []; 98 let current = 0; 99 100 for (const { start, end, feature } of ranges) { 101 if (current < start) { 102 result.push(<span key={current}>{text.slice(current, start)}</span>); 103 } 104 105 const fragment = text.slice(start, end); 106 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 107 if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) { 108 result.push( 109 <a 110 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 111 href={feature.uri} 112 key={start} 113 className="link" 114 style={{ 115 textDecoration: "none", 116 color: "var(--link-text-color)", 117 wordBreak: "break-all", 118 }} 119 target="_blank" 120 rel="noreferrer" 121 onClick={(e) => { 122 e.stopPropagation(); 123 }} 124 > 125 {fragment} 126 </a>, 127 ); 128 } else if ( 129 feature.$type === "app.bsky.richtext.facet#mention" && 130 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 131 feature.did 132 ) { 133 result.push( 134 <span 135 key={start} 136 style={{ color: "var(--link-text-color)" }} 137 className=" cursor-pointer" 138 onClick={(e) => { 139 e.stopPropagation(); 140 navigate({ 141 to: "/profile/$did", 142 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 143 params: { did: feature.did }, 144 }); 145 }} 146 > 147 {fragment} 148 </span>, 149 ); 150 } else if (feature.$type === "app.bsky.richtext.facet#tag") { 151 result.push( 152 <span 153 key={start} 154 style={{ color: "var(--link-text-color)" }} 155 onClick={(e) => { 156 e.stopPropagation(); 157 }} 158 > 159 {fragment} 160 </span>, 161 ); 162 } else { 163 result.push(<span key={start}>{fragment}</span>); 164 } 165 166 current = end; 167 } 168 169 if (current < text.length) { 170 result.push(<span key={current}>{text.slice(current)}</span>); 171 } 172 173 return result; 174} 175 176export function getDomain(url: string) { 177 try { 178 const { hostname } = new URL(url); 179 return hostname; 180 } catch (e) { 181 if (!url.startsWith("http")) { 182 try { 183 const { hostname } = new URL("http://" + url); 184 return hostname; 185 } catch { 186 return null; 187 } 188 } 189 return null; 190 } 191} 192 193export function randomString(length = 8) { 194 const chars = 195 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 196 return Array.from( 197 { length }, 198 () => chars[Math.floor(Math.random() * chars.length)], 199 ).join(""); 200} 201 202export function HitSlopButton({ 203 onClick, 204 children, 205 style = {}, 206 ...rest 207}: React.HTMLAttributes<HTMLSpanElement> & { 208 onClick?: (e: React.MouseEvent) => void; 209 children: React.ReactNode; 210 style?: React.CSSProperties; 211}) { 212 return ( 213 <span 214 style={{ 215 position: "relative", 216 display: "inline-block", 217 cursor: "pointer", 218 }} 219 > 220 <span 221 style={{ 222 position: "absolute", 223 top: -8, 224 left: -8, 225 right: -8, 226 bottom: -8, 227 zIndex: 0, 228 }} 229 onClick={(e) => { 230 e.stopPropagation(); 231 onClick?.(e); 232 }} 233 /> 234 <span 235 style={{ 236 ...style, 237 position: "relative", 238 zIndex: 1, 239 pointerEvents: "none", 240 }} 241 {...rest} 242 > 243 {children} 244 </span> 245 </span> 246 ); 247} 248 249export const btnstyle = { 250 display: "flex", 251 gap: 4, 252 cursor: "pointer", 253 alignItems: "center", 254 fontSize: 14, 255};