atmosphere explorer pds.ls
tool typescript atproto
441
fork

Configure Feed

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

at v1.2.3 619 lines 24 kB view raw
1import { Client, simpleFetchHandler } from "@atcute/client"; 2import { DidDocument } from "@atcute/identity"; 3import { ActorIdentifier, Did, Handle, Nsid } from "@atcute/lexicons"; 4import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 5import { 6 createEffect, 7 createResource, 8 createSignal, 9 ErrorBoundary, 10 For, 11 onMount, 12 Show, 13 Suspense, 14} from "solid-js"; 15import { createStore } from "solid-js/store"; 16import { Backlinks } from "../components/backlinks.jsx"; 17import { 18 ActionMenu, 19 CopyMenu, 20 DropdownMenu, 21 MenuProvider, 22 MenuSeparator, 23 NavMenu, 24} from "../components/dropdown.jsx"; 25import { setPDS } from "../components/navbar.jsx"; 26import { 27 addNotification, 28 removeNotification, 29 updateNotification, 30} from "../components/notification.jsx"; 31import { TextInput } from "../components/text-input.jsx"; 32import Tooltip from "../components/tooltip.jsx"; 33import { 34 didDocCache, 35 labelerCache, 36 resolveHandle, 37 resolveLexiconAuthority, 38 resolvePDS, 39 validateHandle, 40} from "../utils/api.js"; 41import { detectDidKeyType, detectKeyType } from "../utils/key.js"; 42import { BlobView } from "./blob.jsx"; 43import { PlcLogView } from "./logs.jsx"; 44 45export const RepoView = () => { 46 const params = useParams(); 47 const location = useLocation(); 48 const navigate = useNavigate(); 49 const [error, setError] = createSignal<string>(); 50 const [downloading, setDownloading] = createSignal(false); 51 const [didDoc, setDidDoc] = createSignal<DidDocument>(); 52 const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 53 const [filter, setFilter] = createSignal<string>(); 54 const [showFilter, setShowFilter] = createSignal(false); 55 const [validHandles, setValidHandles] = createStore<Record<string, boolean>>({}); 56 const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]); 57 let rpc: Client; 58 let pds: string; 59 const did = params.repo!; 60 61 // Handle scrolling to a collection group when hash is like #collections:app.bsky 62 createEffect(() => { 63 const hash = location.hash; 64 if (hash.startsWith("#collections:")) { 65 const authority = hash.slice(13); 66 requestAnimationFrame(() => { 67 const element = document.getElementById(`collection-${authority}`); 68 if (element) element.scrollIntoView({ behavior: "instant", block: "start" }); 69 }); 70 } 71 }); 72 73 const RepoTab = (props: { 74 tab: "collections" | "backlinks" | "identity" | "blobs" | "logs"; 75 label: string; 76 }) => { 77 const isActive = () => { 78 if (!location.hash) { 79 if (!error() && props.tab === "collections") return true; 80 if (!!error() && props.tab === "identity") return true; 81 return false; 82 } 83 if (props.tab === "collections") 84 return location.hash === "#collections" || location.hash.startsWith("#collections:"); 85 return location.hash === `#${props.tab}`; 86 }; 87 88 return ( 89 <A 90 classList={{ 91 "border-b-2": true, 92 "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": !isActive(), 93 }} 94 href={`/at://${params.repo}#${props.tab}`} 95 > 96 {props.label} 97 </A> 98 ); 99 }; 100 101 const getRotationKeys = async () => { 102 const res = await fetch( 103 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/last`, 104 ); 105 const json = await res.json(); 106 setRotationKeys(json.rotationKeys ?? []); 107 }; 108 109 const fetchRepo = async () => { 110 try { 111 pds = await resolvePDS(did); 112 } catch { 113 if (!did.startsWith("did:")) { 114 try { 115 const did = await resolveHandle(params.repo as Handle); 116 navigate(location.pathname.replace(params.repo!, did), { replace: true }); 117 return; 118 } catch { 119 try { 120 const nsid = params.repo as Nsid; 121 const res = await resolveLexiconAuthority(nsid); 122 navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`, { replace: true }); 123 return; 124 } catch { 125 navigate(`/${did}`, { replace: true }); 126 return; 127 } 128 } 129 } 130 } 131 setDidDoc(didDocCache[did] as DidDocument); 132 getRotationKeys(); 133 134 validateHandles(); 135 136 if (!pds) { 137 setError("Missing PDS"); 138 setPDS("Missing PDS"); 139 return {}; 140 } 141 142 rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 143 try { 144 const res = await rpc.get("com.atproto.repo.describeRepo", { 145 params: { repo: did as ActorIdentifier }, 146 }); 147 if (res.ok) { 148 const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 149 res.data.collections.forEach((c) => { 150 const nsid = c.split("."); 151 if (nsid.length > 2) { 152 const authority = `${nsid[0]}.${nsid[1]}`; 153 collections[authority] = { 154 nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 155 hidden: false, 156 }; 157 } 158 }); 159 setNsids(collections); 160 } else { 161 console.error(res.data.error); 162 switch (res.data.error) { 163 case "RepoDeactivated": 164 setError("Deactivated"); 165 break; 166 case "RepoTakendown": 167 setError("Takendown"); 168 break; 169 default: 170 setError("Unreachable"); 171 } 172 } 173 174 return res.data; 175 } catch { 176 return {}; 177 } 178 }; 179 180 const [repo] = createResource(fetchRepo); 181 182 const validateHandles = async () => { 183 for (const alias of didDoc()?.alsoKnownAs ?? []) { 184 if (alias.startsWith("at://")) 185 setValidHandles( 186 alias, 187 await validateHandle(alias.replace("at://", "") as Handle, did as Did), 188 ); 189 } 190 }; 191 192 const downloadRepo = async () => { 193 let notificationId: string | null = null; 194 195 try { 196 setDownloading(true); 197 notificationId = addNotification({ 198 message: "Downloading repository...", 199 progress: 0, 200 total: 0, 201 type: "info", 202 }); 203 204 const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`); 205 if (!response.ok) { 206 throw new Error(`HTTP error status: ${response.status}`); 207 } 208 209 const contentLength = response.headers.get("content-length"); 210 const total = contentLength ? parseInt(contentLength, 10) : 0; 211 let loaded = 0; 212 213 const reader = response.body?.getReader(); 214 const chunks: BlobPart[] = []; 215 216 if (reader) { 217 while (true) { 218 const { done, value } = await reader.read(); 219 if (done) break; 220 221 chunks.push(value); 222 loaded += value.length; 223 224 if (total > 0) { 225 const progress = Math.round((loaded / total) * 100); 226 updateNotification(notificationId, { 227 progress, 228 total, 229 }); 230 } else { 231 const progressMB = Math.round((loaded / (1024 * 1024)) * 10) / 10; 232 updateNotification(notificationId, { 233 progress: progressMB, 234 total: 0, 235 }); 236 } 237 } 238 } 239 240 const blob = new Blob(chunks); 241 const url = window.URL.createObjectURL(blob); 242 const a = document.createElement("a"); 243 a.href = url; 244 a.download = `${did}-${new Date().toISOString()}.car`; 245 document.body.appendChild(a); 246 a.click(); 247 248 window.URL.revokeObjectURL(url); 249 document.body.removeChild(a); 250 251 updateNotification(notificationId, { 252 message: "Repository downloaded successfully", 253 type: "success", 254 progress: undefined, 255 }); 256 setTimeout(() => { 257 if (notificationId) removeNotification(notificationId); 258 }, 3000); 259 } catch (error) { 260 console.error("Download failed:", error); 261 if (notificationId) { 262 updateNotification(notificationId, { 263 message: "Download failed", 264 type: "error", 265 progress: undefined, 266 }); 267 setTimeout(() => { 268 if (notificationId) removeNotification(notificationId); 269 }, 5000); 270 } 271 } 272 setDownloading(false); 273 }; 274 275 return ( 276 <Show when={repo()}> 277 <div class="flex w-full flex-col gap-3 wrap-break-word"> 278 <div class="dark:shadow-dark-700 dark:bg-dark-300 flex justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-sm shadow-xs dark:border-neutral-700"> 279 <div class="ml-1 flex items-center gap-2 text-xs sm:gap-4 sm:text-sm"> 280 <Show when={!error()}> 281 <RepoTab tab="collections" label="Collections" /> 282 </Show> 283 <RepoTab tab="identity" label="Identity" /> 284 <Show when={did.startsWith("did:plc")}> 285 <RepoTab tab="logs" label="Logs" /> 286 </Show> 287 <Show when={!error()}> 288 <RepoTab tab="blobs" label="Blobs" /> 289 </Show> 290 <RepoTab tab="backlinks" label="Backlinks" /> 291 </div> 292 <div class="flex gap-0.5"> 293 <Show when={error() && error() !== "Missing PDS"}> 294 <div class="flex items-center gap-1 text-red-500 dark:text-red-400"> 295 <span class="iconify lucide--alert-triangle"></span> 296 <span>{error()}</span> 297 </div> 298 </Show> 299 <Show when={!error() && (!location.hash || location.hash.startsWith("#collections"))}> 300 <Tooltip text="Filter collections"> 301 <button 302 class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 303 onClick={() => setShowFilter(!showFilter())} 304 > 305 <span class="iconify lucide--filter"></span> 306 </button> 307 </Tooltip> 308 </Show> 309 <MenuProvider> 310 <DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5"> 311 <CopyMenu content={params.repo!} label="Copy DID" icon="lucide--copy" /> 312 <NavMenu 313 href={`/jetstream?dids=${params.repo}`} 314 label="Jetstream" 315 icon="lucide--radio-tower" 316 /> 317 <Show when={params.repo && params.repo in labelerCache}> 318 <NavMenu 319 href={`/labels?did=${params.repo}&uriPatterns=*`} 320 label="Labels" 321 icon="lucide--tag" 322 /> 323 </Show> 324 <Show when={error()?.length === 0 || error() === undefined}> 325 <ActionMenu 326 label="Export Repo" 327 icon={downloading() ? "lucide--loader-circle animate-spin" : "lucide--download"} 328 onClick={() => downloadRepo()} 329 /> 330 </Show> 331 <MenuSeparator /> 332 <NavMenu 333 href={ 334 did.startsWith("did:plc") ? 335 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}` 336 : `https://${did.split("did:web:")[1]}/.well-known/did.json` 337 } 338 newTab 339 label="DID Document" 340 icon="lucide--external-link" 341 /> 342 <Show when={did.startsWith("did:plc")}> 343 <NavMenu 344 href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`} 345 newTab 346 label="Audit Log" 347 icon="lucide--external-link" 348 /> 349 </Show> 350 </DropdownMenu> 351 </MenuProvider> 352 </div> 353 </div> 354 <div class="flex w-full flex-col gap-1 px-2"> 355 <Show when={location.hash === "#logs"}> 356 <ErrorBoundary 357 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 358 > 359 <Suspense 360 fallback={ 361 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 362 } 363 > 364 <PlcLogView did={did} /> 365 </Suspense> 366 </ErrorBoundary> 367 </Show> 368 <Show when={location.hash === "#backlinks"}> 369 <ErrorBoundary 370 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 371 > 372 <Suspense 373 fallback={ 374 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 375 } 376 > 377 <Backlinks target={did} /> 378 </Suspense> 379 </ErrorBoundary> 380 </Show> 381 <Show when={location.hash === "#blobs"}> 382 <ErrorBoundary 383 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 384 > 385 <Suspense 386 fallback={ 387 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 388 } 389 > 390 <BlobView pds={pds!} repo={did} /> 391 </Suspense> 392 </ErrorBoundary> 393 </Show> 394 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 395 <Show when={showFilter()}> 396 <TextInput 397 name="filter" 398 placeholder="Filter collections" 399 onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 400 class="grow" 401 ref={(node) => { 402 onMount(() => node.focus()); 403 }} 404 /> 405 </Show> 406 <div class="flex flex-col text-sm wrap-anywhere" classList={{ "-mt-1": !showFilter() }}> 407 <Show 408 when={Object.keys(nsids() ?? {}).length != 0} 409 fallback={<span class="mt-3 text-center text-base">No collections found.</span>} 410 > 411 <For 412 each={Object.keys(nsids() ?? {}).filter((authority) => 413 filter() ? 414 authority.includes(filter()!) || 415 nsids()?.[authority].nsids.some((nsid) => 416 `${authority}.${nsid}`.includes(filter()!), 417 ) 418 : true, 419 )} 420 > 421 {(authority) => { 422 const reversedDomain = authority.split(".").reverse().join("."); 423 const [faviconLoaded, setFaviconLoaded] = createSignal(false); 424 425 const isHighlighted = () => location.hash === `#collections:${authority}`; 426 427 return ( 428 <div 429 id={`collection-${authority}`} 430 class="group flex items-start gap-2 rounded-lg p-1 transition-colors" 431 classList={{ 432 "dark:hover:bg-dark-200 hover:bg-neutral-200": !isHighlighted(), 433 "bg-blue-100 dark:bg-blue-500/25": isHighlighted(), 434 }} 435 > 436 <a 437 href={`#collections:${authority}`} 438 class="relative flex h-5 w-4 shrink-0 items-center justify-center hover:opacity-70" 439 > 440 <span class="absolute top-1/2 -left-5 flex -translate-y-1/2 items-center text-base opacity-0 transition-opacity group-hover:opacity-100"> 441 <span class="iconify lucide--link absolute -left-2 w-7"></span> 442 </span> 443 <Show when={!faviconLoaded()}> 444 <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 445 </Show> 446 <img 447 src={ 448 ["bsky.app", "bsky.chat"].includes(reversedDomain) ? 449 "https://web-cdn.bsky.app/static/apple-touch-icon.png" 450 : `https://${reversedDomain}/favicon.ico` 451 } 452 alt={`${reversedDomain} favicon`} 453 class="h-4 w-4" 454 classList={{ hidden: !faviconLoaded() }} 455 onLoad={() => setFaviconLoaded(true)} 456 onError={() => setFaviconLoaded(false)} 457 /> 458 </a> 459 <div class="flex flex-1 flex-col"> 460 <For 461 each={nsids()?.[authority].nsids.filter((nsid) => 462 filter() ? `${authority}.${nsid}`.includes(filter()!) : true, 463 )} 464 > 465 {(nsid) => ( 466 <A 467 href={`/at://${did}/${authority}.${nsid}`} 468 class="hover:underline active:underline" 469 > 470 <span>{authority}</span> 471 <span class="text-neutral-500 dark:text-neutral-400">.{nsid}</span> 472 </A> 473 )} 474 </For> 475 </div> 476 </div> 477 ); 478 }} 479 </For> 480 </Show> 481 </div> 482 </Show> 483 <Show when={location.hash === "#identity" || (error() && !location.hash)}> 484 <Show when={didDoc()}> 485 {(didDocument) => ( 486 <div class="flex flex-col gap-3 wrap-anywhere"> 487 {/* ID Section */} 488 <div> 489 <div class="flex items-center gap-1"> 490 <div class="iconify lucide--id-card" /> 491 <p class="font-semibold">ID</p> 492 </div> 493 <div class="text-sm">{didDocument().id}</div> 494 </div> 495 496 {/* Aliases Section */} 497 <div> 498 <div class="flex items-center gap-1"> 499 <div class="iconify lucide--at-sign" /> 500 <p class="font-semibold">Aliases</p> 501 </div> 502 <div class="flex flex-col gap-0.5"> 503 <For each={didDocument().alsoKnownAs}> 504 {(alias) => ( 505 <div class="flex items-center gap-1 text-sm"> 506 <span>{alias}</span> 507 <Show when={alias.startsWith("at://")}> 508 <Tooltip 509 text={ 510 validHandles[alias] === true ? "Valid handle" 511 : validHandles[alias] === undefined ? 512 "Validating" 513 : "Invalid handle" 514 } 515 > 516 <span 517 classList={{ 518 "iconify lucide--circle-check text-green-600 dark:text-green-400": 519 validHandles[alias] === true, 520 "iconify lucide--circle-x text-red-500 dark:text-red-400": 521 validHandles[alias] === false, 522 "iconify lucide--loader-circle animate-spin": 523 validHandles[alias] === undefined, 524 }} 525 ></span> 526 </Tooltip> 527 </Show> 528 </div> 529 )} 530 </For> 531 </div> 532 </div> 533 534 {/* Services Section */} 535 <div> 536 <div class="flex items-center gap-1"> 537 <div class="iconify lucide--hard-drive" /> 538 <p class="font-semibold">Services</p> 539 </div> 540 <div class="flex flex-col gap-0.5"> 541 <For each={didDocument().service}> 542 {(service) => ( 543 <div class="text-sm"> 544 <div class="font-medium text-neutral-700 dark:text-neutral-300"> 545 #{service.id.split("#")[1]} 546 </div> 547 <a 548 class="underline hover:text-blue-400" 549 href={service.serviceEndpoint.toString()} 550 target="_blank" 551 rel="noopener" 552 > 553 {service.serviceEndpoint.toString()} 554 </a> 555 </div> 556 )} 557 </For> 558 </div> 559 </div> 560 561 {/* Verification Methods Section */} 562 <div> 563 <div class="flex items-center gap-1"> 564 <div class="iconify lucide--shield-check" /> 565 <p class="font-semibold">Verification Methods</p> 566 </div> 567 <div class="flex flex-col gap-0.5"> 568 <For each={didDocument().verificationMethod}> 569 {(verif) => ( 570 <Show when={verif.publicKeyMultibase}> 571 {(key) => ( 572 <div class="text-sm"> 573 <div class="flex items-baseline gap-1"> 574 <span class="font-medium text-neutral-700 dark:text-neutral-300"> 575 #{verif.id.split("#")[1]} 576 </span> 577 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300"> 578 {detectKeyType(key())} 579 </span> 580 </div> 581 <div class="font-mono break-all">{key()}</div> 582 </div> 583 )} 584 </Show> 585 )} 586 </For> 587 </div> 588 </div> 589 590 {/* Rotation Keys Section */} 591 <Show when={rotationKeys().length > 0}> 592 <div> 593 <div class="flex items-center gap-1"> 594 <div class="iconify lucide--key-round" /> 595 <p class="font-semibold">Rotation Keys</p> 596 </div> 597 <div class="flex flex-col gap-0.5"> 598 <For each={rotationKeys()}> 599 {(key) => ( 600 <div class="text-sm"> 601 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300"> 602 {detectDidKeyType(key)} 603 </span> 604 <div class="font-mono break-all">{key.replace("did:key:", "")}</div> 605 </div> 606 )} 607 </For> 608 </div> 609 </div> 610 </Show> 611 </div> 612 )} 613 </Show> 614 </Show> 615 </div> 616 </div> 617 </Show> 618 ); 619};