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