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