atmosphere explorer
0
fork

Configure Feed

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

at main 489 lines 16 kB view raw
1import { isCid, isDid, isNsid, isResourceUri, Nsid } from "@atcute/lexicons/syntax"; 2import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 3import { 4 createContext, 5 createEffect, 6 createResource, 7 createSignal, 8 ErrorBoundary, 9 For, 10 onCleanup, 11 Show, 12 useContext, 13} from "solid-js"; 14import { Portal } from "solid-js/web"; 15import { resolveLexiconAuthority } from "../lib/api"; 16import { formatFileSize } from "../utils/format"; 17import { hideMedia } from "../views/settings"; 18import DidHoverCard from "./hover-card/did"; 19import RecordHoverCard from "./hover-card/record"; 20import { addNotification, removeNotification } from "./notification"; 21import VideoPlayer from "./video-player"; 22 23interface JSONContext { 24 repo: string; 25 pds?: string; 26 truncate?: boolean; 27 parentIsBlob?: boolean; 28 newTab?: boolean; 29 hideBlobs?: boolean; 30 keyLinks?: boolean; 31 path?: string; 32} 33 34const JSONCtx = createContext<JSONContext>(); 35const useJSONCtx = () => useContext(JSONCtx)!; 36 37interface AtBlob { 38 $type: string; 39 ref: { $link: string }; 40 mimeType: string; 41 size: number; 42} 43 44const isURL = 45 URL.canParse ?? 46 ((url, base) => { 47 try { 48 new URL(url, base); 49 return true; 50 } catch { 51 return false; 52 } 53 }); 54 55const JSONString = (props: { data: string; isType?: boolean; isLink?: boolean }) => { 56 const ctx = useJSONCtx(); 57 const navigate = useNavigate(); 58 const params = useParams(); 59 60 const handleClick = async (lex: string) => { 61 try { 62 const [nsid, anchor] = lex.split("#"); 63 const authority = await resolveLexiconAuthority(nsid as Nsid); 64 65 const hash = anchor ? `#schema:${anchor}` : "#schema"; 66 if (ctx.newTab) 67 window.open(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`, "_blank"); 68 else navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 69 } catch (err) { 70 console.error("Failed to resolve lexicon authority:", err); 71 const id = addNotification({ 72 message: "Could not resolve schema", 73 type: "error", 74 }); 75 setTimeout(() => removeNotification(id), 5000); 76 } 77 }; 78 79 const MAX_LENGTH = 200; 80 const isTruncated = () => ctx.truncate && props.data.length > MAX_LENGTH; 81 const displayData = () => (isTruncated() ? props.data.slice(0, MAX_LENGTH) : props.data); 82 const remainingChars = () => props.data.length - MAX_LENGTH; 83 84 return ( 85 <span> 86 <span class="text-neutral-500 dark:text-neutral-400">"</span> 87 <For each={displayData().split(/(\s)/)}> 88 {(part) => ( 89 <> 90 {isResourceUri(part) ? 91 <RecordHoverCard uri={part} newTab={ctx.newTab} /> 92 : isDid(part) ? 93 <DidHoverCard did={part} newTab={ctx.newTab} /> 94 : isNsid(part.split("#")[0]) && props.isType ? 95 <button 96 type="button" 97 onClick={() => handleClick(part)} 98 class="cursor-pointer text-blue-500 hover:underline active:underline dark:text-blue-400" 99 > 100 {part} 101 </button> 102 : isCid(part) && props.isLink && ctx.parentIsBlob && params.repo ? 103 <A 104 class="text-blue-500 hover:underline active:underline dark:text-blue-400" 105 rel="noopener" 106 target="_blank" 107 href={`${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${params.repo}&cid=${part}`} 108 > 109 {part} 110 </A> 111 : ( 112 isURL(part) && 113 ["http:", "https:", "web+at:"].includes(new URL(part).protocol) && 114 part.split("\n").length === 1 115 ) ? 116 <a 117 class="underline hover:text-blue-500 dark:hover:text-blue-400" 118 href={part} 119 target="_blank" 120 rel="noopener" 121 > 122 {part} 123 </a> 124 : part} 125 </> 126 )} 127 </For> 128 <Show when={isTruncated()}> 129 <span>…</span> 130 </Show> 131 <span class="text-neutral-500 dark:text-neutral-400">"</span> 132 <Show when={isTruncated()}> 133 <span class="ml-1 text-neutral-500 dark:text-neutral-400"> 134 (+{remainingChars().toLocaleString()}) 135 </span> 136 </Show> 137 </span> 138 ); 139}; 140 141const JSONNumber = ({ data, isSize }: { data: number; isSize?: boolean }) => { 142 return ( 143 <span class="flex gap-1"> 144 {data} 145 <Show when={isSize}> 146 <span class="text-neutral-500 dark:text-neutral-400">({formatFileSize(data)})</span> 147 </Show> 148 </span> 149 ); 150}; 151 152const CollapsibleItem = (props: { 153 label: string | number; 154 value: JSONType; 155 maxWidth?: string; 156 isType?: boolean; 157 isLink?: boolean; 158 isSize?: boolean; 159 isIndex?: boolean; 160 parentIsBlob?: boolean; 161}) => { 162 const ctx = useJSONCtx(); 163 const location = useLocation(); 164 const [show, setShow] = createSignal(true); 165 const isBlobContext = props.parentIsBlob ?? ctx.parentIsBlob; 166 167 const labelStr = () => { 168 const l = String(props.label); 169 return l.startsWith("#") ? l.slice(1) : l; 170 }; 171 const fullPath = () => (ctx.path ? `${ctx.path}.${labelStr()}` : labelStr()); 172 const isHighlighted = () => location.hash === `#record:${fullPath()}`; 173 174 createEffect(() => { 175 if (isHighlighted()) { 176 requestAnimationFrame(() => { 177 document 178 .getElementById(`key-${fullPath()}`) 179 ?.scrollIntoView({ behavior: "instant", block: "center" }); 180 }); 181 } 182 }); 183 184 const isObject = () => props.value === Object(props.value); 185 const isEmpty = () => 186 Array.isArray(props.value) ? 187 (props.value as JSONType[]).length === 0 188 : Object.keys(props.value as object).length === 0; 189 const summary = () => { 190 if (Array.isArray(props.value)) { 191 const len = (props.value as JSONType[]).length; 192 return `[ ${len} ${len === 1 ? "item" : "items"} ]`; 193 } 194 const len = Object.keys(props.value as object).length; 195 return `{ ${len} ${len === 1 ? "key" : "keys"} }`; 196 }; 197 198 return ( 199 <span 200 classList={{ 201 "group/indent flex gap-x-1 w-full": true, 202 "flex-col": isObject() && !isEmpty(), 203 }} 204 > 205 <span 206 class="relative flex size-fit shrink-0 items-center gap-x-1 wrap-anywhere" 207 classList={{ "max-w-[40%] sm:max-w-[50%]": props.maxWidth !== undefined && show() }} 208 > 209 <Show 210 when={ctx.keyLinks} 211 fallback={ 212 <span 213 classList={{ 214 "text-indigo-500 dark:text-indigo-400": !props.isIndex, 215 "text-violet-500 dark:text-violet-400": props.isIndex, 216 }} 217 > 218 {props.label} 219 <span class="text-neutral-500 dark:text-neutral-400">:</span> 220 </span> 221 } 222 > 223 <a 224 href={`#record:${fullPath()}`} 225 id={`key-${fullPath()}`} 226 class="group/key rounded" 227 classList={{ 228 "text-indigo-500 hover:text-indigo-700 active:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 dark:active:text-indigo-200": 229 !props.isIndex && !isHighlighted(), 230 "text-violet-500 hover:text-violet-700 active:text-violet-800 dark:text-violet-400 dark:hover:text-violet-300 dark:active:text-violet-200": 231 props.isIndex && !isHighlighted(), 232 "bg-indigo-200 text-indigo-700 dark:bg-indigo-500/60 dark:text-indigo-200": 233 isHighlighted() && !props.isIndex, 234 "bg-violet-200 text-violet-700 dark:bg-violet-500/60 dark:text-violet-200": 235 isHighlighted() && props.isIndex, 236 }} 237 > 238 <span class="absolute top-1/2 -left-3.5 flex -translate-y-1/2 items-center text-xs text-neutral-500 opacity-0 transition-opacity group-hover/key:opacity-100 dark:text-neutral-400"> 239 <span class="iconify lucide--link"></span> 240 </span> 241 {props.label} 242 <span class="text-neutral-500 dark:text-neutral-400">:</span> 243 </a> 244 </Show> 245 <Show when={!show() && summary()}> 246 <button 247 type="button" 248 class="flex items-center gap-0.5 rounded bg-neutral-200 px-1 text-xs whitespace-nowrap text-neutral-500 hover:bg-neutral-300 hover:text-neutral-700 sm:py-0.5 dark:bg-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-600 dark:hover:text-neutral-200" 249 onclick={() => setShow(true)} 250 > 251 <span class="iconify lucide--chevron-right"></span> 252 {summary()} 253 </button> 254 </Show> 255 </span> 256 <span 257 classList={{ 258 "self-center": !isObject() || isEmpty(), 259 "relative pl-[2ch]": isObject() && !isEmpty(), 260 "invisible h-0 overflow-hidden": !show(), 261 }} 262 > 263 <Show when={isObject() && !isEmpty()}> 264 <span 265 class="group/fold absolute inset-y-0 left-0 z-10 flex w-4 -translate-x-1/2 items-center justify-center" 266 onclick={() => setShow(!show())} 267 > 268 <span class="h-full w-px bg-neutral-300 transition-colors group-hover/fold:bg-neutral-600 dark:bg-neutral-600 dark:group-hover/fold:bg-neutral-300" /> 269 </span> 270 </Show> 271 <JSONCtx.Provider value={{ ...ctx, parentIsBlob: isBlobContext, path: fullPath() }}> 272 <JSONValueInner 273 data={props.value} 274 isType={props.isType} 275 isLink={props.isLink} 276 isSize={props.isSize} 277 /> 278 </JSONCtx.Provider> 279 </span> 280 </span> 281 ); 282}; 283 284const JSONObject = (props: { data: { [x: string]: JSONType } }) => { 285 const ctx = useJSONCtx(); 286 const params = useParams(); 287 const [hide, setHide] = createSignal( 288 localStorage.hideMedia === "true" || params.rkey === undefined, 289 ); 290 createEffect(() => { 291 if (hideMedia()) setHide(hideMedia()); 292 }); 293 294 const isBlob = props.data.$type === "blob"; 295 const isBlobContext = isBlob || ctx.parentIsBlob; 296 297 const rawObj = ( 298 <For each={Object.entries(props.data)}> 299 {([key, value]) => ( 300 <CollapsibleItem 301 label={key} 302 value={value} 303 maxWidth="set" 304 isType={key === "$type"} 305 isLink={key === "$link"} 306 isSize={key === "size" && isBlob} 307 parentIsBlob={isBlobContext} 308 /> 309 )} 310 </For> 311 ); 312 313 const blob: AtBlob = props.data as any; 314 const canShowMedia = () => 315 ctx.pds && 316 !ctx.hideBlobs && 317 (blob.mimeType.startsWith("image/") || 318 blob.mimeType === "video/mp4" || 319 blob.mimeType.startsWith("audio/")); 320 321 const MediaDisplay = () => { 322 const [expanded, setExpanded] = createSignal(false); 323 const [closing, setClosing] = createSignal(false); 324 325 const closeExpanded = () => { 326 setClosing(true); 327 setTimeout(() => { 328 setExpanded(false); 329 setClosing(false); 330 }, 200); 331 }; 332 333 createEffect(() => { 334 if (!expanded()) return; 335 const handler = (e: KeyboardEvent) => { 336 if (e.key === "Escape") closeExpanded(); 337 }; 338 window.addEventListener("keydown", handler); 339 onCleanup(() => window.removeEventListener("keydown", handler)); 340 }); 341 const [imageUrl] = createResource( 342 () => (blob.mimeType.startsWith("image/") ? blob.ref.$link : null), 343 async (cid) => { 344 const url = `${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`; 345 346 await new Promise<void>((resolve) => { 347 const img = new Image(); 348 img.src = url; 349 img.onload = () => resolve(); 350 img.onerror = () => resolve(); 351 }); 352 353 return url; 354 }, 355 ); 356 357 return ( 358 <div> 359 <span class="group/media relative my-0.5 flex w-fit"> 360 <Show when={!hide()}> 361 <Show when={blob.mimeType.startsWith("image/")}> 362 <Show 363 when={!imageUrl.loading && imageUrl()} 364 fallback={ 365 <div class="flex h-48 w-48 items-center justify-center rounded bg-neutral-200 dark:bg-neutral-800"> 366 <span class="iconify lucide--loader-circle animate-spin text-xl text-neutral-400 dark:text-neutral-500"></span> 367 </div> 368 } 369 > 370 <img 371 class="h-auto max-h-48 max-w-64 cursor-zoom-in object-contain" 372 src={imageUrl()} 373 onclick={() => setExpanded(true)} 374 /> 375 <Show when={expanded()}> 376 <Portal> 377 <div 378 class="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/80 transition-opacity duration-200 starting:opacity-0" 379 classList={{ "opacity-0": closing() }} 380 onclick={closeExpanded} 381 > 382 <img 383 class="max-h-screen max-w-screen object-contain transition-all duration-200 starting:scale-95 starting:opacity-0" 384 classList={{ "scale-95 opacity-0": closing() }} 385 src={imageUrl()} 386 /> 387 </div> 388 </Portal> 389 </Show> 390 </Show> 391 </Show> 392 <Show when={blob.mimeType === "video/mp4"}> 393 <ErrorBoundary fallback={() => <span>Failed to load video</span>}> 394 <VideoPlayer did={ctx.repo} cid={blob.ref.$link} /> 395 </ErrorBoundary> 396 </Show> 397 <Show when={blob.mimeType.startsWith("audio/")}> 398 <audio class="my-0.5 max-w-96" controls> 399 <source 400 src={`${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${blob.ref.$link}`} 401 type={blob.mimeType === "audio/x-flac" ? "audio/flac" : blob.mimeType} 402 /> 403 </audio> 404 </Show> 405 </Show> 406 <Show when={hide()}> 407 <button 408 onclick={() => setHide(false)} 409 class="flex items-center gap-1 rounded-md bg-neutral-200 px-2 py-1.5 text-sm transition-colors hover:bg-neutral-300 active:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 410 > 411 <span class="iconify lucide--image"></span> 412 <span class="font-sans">Show media</span> 413 </button> 414 </Show> 415 </span> 416 </div> 417 ); 418 }; 419 420 if (Object.keys(props.data).length === 0) 421 return <span class="text-neutral-400 dark:text-neutral-500">{"{ }"}</span>; 422 423 if (blob.$type === "blob") { 424 return ( 425 <> 426 <Show when={canShowMedia()}> 427 <MediaDisplay /> 428 </Show> 429 {rawObj} 430 </> 431 ); 432 } 433 434 return rawObj; 435}; 436 437const JSONArray = (props: { data: JSONType[] }) => { 438 if (props.data.length === 0) 439 return <span class="text-neutral-400 dark:text-neutral-500">[ ]</span>; 440 return ( 441 <For each={props.data}> 442 {(value, index) => <CollapsibleItem label={`#${index()}`} value={value} isIndex />} 443 </For> 444 ); 445}; 446 447const JSONValueInner = (props: { 448 data: JSONType; 449 isType?: boolean; 450 isLink?: boolean; 451 isSize?: boolean; 452}) => { 453 const data = props.data; 454 if (typeof data === "string") 455 return <JSONString data={data} isType={props.isType} isLink={props.isLink} />; 456 if (typeof data === "number") return <JSONNumber data={data} isSize={props.isSize} />; 457 if (typeof data === "boolean") 458 return <span class="text-amber-500 dark:text-amber-400">{String(data)}</span>; 459 if (data === null) return <span class="text-neutral-400 dark:text-neutral-500">null</span>; 460 if (Array.isArray(data)) return <JSONArray data={data} />; 461 return <JSONObject data={data} />; 462}; 463 464export const JSONValue = (props: { 465 data: JSONType; 466 repo: string; 467 pds?: string; 468 truncate?: boolean; 469 newTab?: boolean; 470 hideBlobs?: boolean; 471 keyLinks?: boolean; 472}) => { 473 return ( 474 <JSONCtx.Provider 475 value={{ 476 repo: props.repo, 477 pds: props.pds, 478 truncate: props.truncate, 479 newTab: props.newTab, 480 hideBlobs: props.hideBlobs, 481 keyLinks: props.keyLinks, 482 }} 483 > 484 <JSONValueInner data={props.data} /> 485 </JSONCtx.Provider> 486 ); 487}; 488 489export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];