atproto explorer
0
fork

Configure Feed

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

at main 607 lines 23 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 font-medium": true, 92 "border-transparent text-neutral-600 dark:text-neutral-300/80 hover:border-neutral-600 dark:hover:border-neutral-300/80": 93 !isActive(), 94 }} 95 href={`/at://${params.repo}#${props.tab}`} 96 > 97 {props.label} 98 </A> 99 ); 100 }; 101 102 const getRotationKeys = async () => { 103 const res = await fetch( 104 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/last`, 105 ); 106 const json = await res.json(); 107 setRotationKeys(json.rotationKeys ?? []); 108 }; 109 110 const fetchRepo = async () => { 111 try { 112 pds = await resolvePDS(did); 113 } catch { 114 if (!did.startsWith("did:")) { 115 try { 116 const did = await resolveHandle(params.repo as Handle); 117 navigate(location.pathname.replace(params.repo!, did), { replace: true }); 118 return; 119 } catch { 120 try { 121 const nsid = params.repo as Nsid; 122 const res = await resolveLexiconAuthority(nsid); 123 navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`, { replace: true }); 124 return; 125 } catch { 126 navigate(`/${did}`, { replace: true }); 127 return; 128 } 129 } 130 } 131 } 132 setDidDoc(didDocCache[did] as DidDocument); 133 getRotationKeys(); 134 135 validateHandles(); 136 137 if (!pds) { 138 setError("Missing PDS"); 139 setPDS("Missing PDS"); 140 return {}; 141 } 142 143 rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 144 try { 145 const res = await rpc.get("com.atproto.repo.describeRepo", { 146 params: { repo: did as ActorIdentifier }, 147 }); 148 if (res.ok) { 149 const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 150 res.data.collections.forEach((c) => { 151 const nsid = c.split("."); 152 if (nsid.length > 2) { 153 const authority = `${nsid[0]}.${nsid[1]}`; 154 collections[authority] = { 155 nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 156 hidden: false, 157 }; 158 } 159 }); 160 setNsids(collections); 161 } else { 162 console.error(res.data.error); 163 switch (res.data.error) { 164 case "RepoDeactivated": 165 setError("Deactivated"); 166 break; 167 case "RepoTakendown": 168 setError("Takendown"); 169 break; 170 default: 171 setError("Unreachable"); 172 } 173 } 174 175 return res.data; 176 } catch { 177 return {}; 178 } 179 }; 180 181 const [repo] = createResource(fetchRepo); 182 183 const validateHandles = async () => { 184 for (const alias of didDoc()?.alsoKnownAs ?? []) { 185 if (alias.startsWith("at://")) 186 setValidHandles( 187 alias, 188 await validateHandle(alias.replace("at://", "") as Handle, did as Did), 189 ); 190 } 191 }; 192 193 const downloadRepo = async () => { 194 let notificationId: string | null = null; 195 196 try { 197 setDownloading(true); 198 notificationId = addNotification({ 199 message: "Downloading repository...", 200 progress: 0, 201 total: 0, 202 type: "info", 203 }); 204 205 const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`); 206 if (!response.ok) { 207 throw new Error(`HTTP error status: ${response.status}`); 208 } 209 210 const contentLength = response.headers.get("content-length"); 211 const total = contentLength ? parseInt(contentLength, 10) : 0; 212 let loaded = 0; 213 214 const reader = response.body?.getReader(); 215 const chunks: BlobPart[] = []; 216 217 if (reader) { 218 while (true) { 219 const { done, value } = await reader.read(); 220 if (done) break; 221 222 chunks.push(value); 223 loaded += value.length; 224 225 if (total > 0) { 226 const progress = Math.round((loaded / total) * 100); 227 updateNotification(notificationId, { 228 progress, 229 total, 230 }); 231 } else { 232 const progressMB = Math.round((loaded / (1024 * 1024)) * 10) / 10; 233 updateNotification(notificationId, { 234 progress: progressMB, 235 total: 0, 236 }); 237 } 238 } 239 } 240 241 const blob = new Blob(chunks); 242 const url = window.URL.createObjectURL(blob); 243 const a = document.createElement("a"); 244 a.href = url; 245 a.download = `${did}-${new Date().toISOString()}.car`; 246 document.body.appendChild(a); 247 a.click(); 248 249 window.URL.revokeObjectURL(url); 250 document.body.removeChild(a); 251 252 updateNotification(notificationId, { 253 message: "Repository downloaded successfully", 254 type: "success", 255 progress: undefined, 256 }); 257 setTimeout(() => { 258 if (notificationId) removeNotification(notificationId); 259 }, 3000); 260 } catch (error) { 261 console.error("Download failed:", error); 262 if (notificationId) { 263 updateNotification(notificationId, { 264 message: "Download failed", 265 type: "error", 266 progress: undefined, 267 }); 268 setTimeout(() => { 269 if (notificationId) removeNotification(notificationId); 270 }, 5000); 271 } 272 } 273 setDownloading(false); 274 }; 275 276 return ( 277 <Show when={repo()}> 278 <div class="flex w-full flex-col gap-3 wrap-break-word"> 279 <div class="flex justify-between px-2 text-sm sm:text-base"> 280 <div class="flex items-center gap-3 sm:gap-4"> 281 <Show when={!error()}> 282 <RepoTab tab="collections" label="Collections" /> 283 </Show> 284 <RepoTab tab="identity" label="Identity" /> 285 <Show when={did.startsWith("did:plc")}> 286 <RepoTab tab="logs" label="Logs" /> 287 </Show> 288 <Show when={!error()}> 289 <RepoTab tab="blobs" label="Blobs" /> 290 </Show> 291 <RepoTab tab="backlinks" label="Backlinks" /> 292 </div> 293 <div class="flex gap-1"> 294 <Show when={error() && error() !== "Missing PDS"}> 295 <div class="flex items-center gap-1 text-red-500 dark:text-red-400"> 296 <span class="iconify lucide--alert-triangle"></span> 297 <span>{error()}</span> 298 </div> 299 </Show> 300 <MenuProvider> 301 <DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5"> 302 <Show 303 when={!error() && (!location.hash || location.hash.startsWith("#collections"))} 304 > 305 <ActionMenu 306 label="Filter collections" 307 icon="lucide--filter" 308 onClick={() => setShowFilter(!showFilter())} 309 /> 310 </Show> 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="font-semibold">DID</div> 490 <div class="text-sm text-neutral-700 dark:text-neutral-300"> 491 {didDocument().id} 492 </div> 493 </div> 494 495 {/* Aliases Section */} 496 <div> 497 <p class="font-semibold">Aliases</p> 498 <For each={didDocument().alsoKnownAs}> 499 {(alias) => ( 500 <div class="flex items-center gap-1 text-sm text-neutral-700 dark:text-neutral-300"> 501 <span>{alias}</span> 502 <Show when={alias.startsWith("at://")}> 503 <Tooltip 504 text={ 505 validHandles[alias] === true ? "Valid handle" 506 : validHandles[alias] === undefined ? 507 "Validating" 508 : "Invalid handle" 509 } 510 > 511 <span 512 classList={{ 513 "iconify lucide--check text-green-600 dark:text-green-400": 514 validHandles[alias] === true, 515 "iconify lucide--x text-red-500 dark:text-red-400": 516 validHandles[alias] === false, 517 "iconify lucide--loader-circle animate-spin": 518 validHandles[alias] === undefined, 519 }} 520 ></span> 521 </Tooltip> 522 </Show> 523 </div> 524 )} 525 </For> 526 </div> 527 528 {/* Services Section */} 529 <div> 530 <p class="font-semibold">Services</p> 531 <div class="flex flex-col gap-1"> 532 <For each={didDocument().service}> 533 {(service) => ( 534 <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 535 <span class="iconify lucide--hash"></span> 536 <span>{service.id.split("#")[1]}</span> 537 <span></span> 538 <a 539 class="w-fit underline hover:text-blue-400" 540 href={service.serviceEndpoint.toString()} 541 target="_blank" 542 rel="noopener" 543 > 544 {service.serviceEndpoint.toString()} 545 </a> 546 </div> 547 )} 548 </For> 549 </div> 550 </div> 551 552 {/* Verification Methods Section */} 553 <div> 554 <p class="font-semibold">Verification Methods</p> 555 <div class="flex flex-col gap-1"> 556 <For each={didDocument().verificationMethod}> 557 {(verif) => ( 558 <Show when={verif.publicKeyMultibase}> 559 {(key) => ( 560 <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 561 <span class="iconify lucide--hash"></span> 562 <div class="flex items-center gap-2"> 563 <span>{verif.id.split("#")[1]}</span> 564 <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400"> 565 <span class="iconify lucide--key-round"></span> 566 <span>{detectKeyType(key())}</span> 567 </div> 568 </div> 569 <span></span> 570 <div class="font-mono break-all">{key()}</div> 571 </div> 572 )} 573 </Show> 574 )} 575 </For> 576 </div> 577 </div> 578 579 {/* Rotation Keys Section */} 580 <Show when={rotationKeys().length > 0}> 581 <div> 582 <p class="font-semibold">Rotation Keys</p> 583 <div class="flex flex-col gap-1"> 584 <For each={rotationKeys()}> 585 {(key) => ( 586 <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 587 <span class="iconify lucide--key-round text-neutral-500 dark:text-neutral-400"></span> 588 <span class="text-neutral-500 dark:text-neutral-400"> 589 {detectDidKeyType(key)} 590 </span> 591 <span></span> 592 <div class="font-mono break-all">{key.replace("did:key:", "")}</div> 593 </div> 594 )} 595 </For> 596 </div> 597 </div> 598 </Show> 599 </div> 600 )} 601 </Show> 602 </Show> 603 </div> 604 </div> 605 </Show> 606 ); 607};