an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
94
fork

Configure Feed

Select the types of activity you want to include in your feed.

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};