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="flex items-center" href={`/at://${params.repo}#${props.tab}`}>
65 <span
66 classList={{
67 "flex items-center border-b-2": true,
68 "border-transparent hover:border-neutral-400 dark: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 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">
254 <div class="ml-1 flex gap-2 text-xs sm:gap-4 sm:text-sm">
255 <Show when={!error()}>
256 <RepoTab tab="collections" label="Collections" />
257 </Show>
258 <RepoTab tab="identity" label="Identity" />
259 <Show when={did.startsWith("did:plc")}>
260 <RepoTab tab="logs" label="Logs" />
261 </Show>
262 <Show when={!error()}>
263 <RepoTab tab="blobs" label="Blobs" />
264 </Show>
265 <RepoTab tab="backlinks" label="Backlinks" />
266 </div>
267 <div class="flex gap-0.5">
268 <Show when={error() && error() !== "Missing PDS"}>
269 <div class="flex items-center gap-1 text-red-500 dark:text-red-400">
270 <span class="iconify lucide--alert-triangle"></span>
271 <span>{error()}</span>
272 </div>
273 </Show>
274 <Show when={!error() && (!location.hash || location.hash === "#collections")}>
275 <Tooltip text="Filter collections">
276 <button
277 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"
278 onClick={() => setShowFilter(!showFilter())}
279 >
280 <span class="iconify lucide--filter"></span>
281 </button>
282 </Tooltip>
283 </Show>
284 <MenuProvider>
285 <DropdownMenu
286 icon="lucide--ellipsis-vertical"
287 buttonClass="rounded-sm p-1.5"
288 menuClass="top-9 p-2 text-sm"
289 >
290 <CopyMenu content={params.repo!} label="Copy DID" icon="lucide--copy" />
291 <NavMenu
292 href={`/jetstream?dids=${params.repo}`}
293 label="Jetstream"
294 icon="lucide--radio-tower"
295 />
296 <Show when={params.repo && params.repo in labelerCache}>
297 <NavMenu
298 href={`/labels?did=${params.repo}&uriPatterns=*`}
299 label="Labels"
300 icon="lucide--tag"
301 />
302 </Show>
303 <Show when={error()?.length === 0 || error() === undefined}>
304 <ActionMenu
305 label="Export Repo"
306 icon={downloading() ? "lucide--loader-circle animate-spin" : "lucide--download"}
307 onClick={() => downloadRepo()}
308 />
309 </Show>
310 <MenuSeparator />
311 <NavMenu
312 href={
313 did.startsWith("did:plc") ?
314 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}`
315 : `https://${did.split("did:web:")[1]}/.well-known/did.json`
316 }
317 newTab
318 label="DID Document"
319 icon="lucide--external-link"
320 />
321 <Show when={did.startsWith("did:plc")}>
322 <NavMenu
323 href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`}
324 newTab
325 label="Audit Log"
326 icon="lucide--external-link"
327 />
328 </Show>
329 </DropdownMenu>
330 </MenuProvider>
331 </div>
332 </div>
333 <div class="flex w-full flex-col gap-1 px-2">
334 <Show when={location.hash === "#logs"}>
335 <ErrorBoundary
336 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
337 >
338 <Suspense
339 fallback={
340 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" />
341 }
342 >
343 <PlcLogView did={did} />
344 </Suspense>
345 </ErrorBoundary>
346 </Show>
347 <Show when={location.hash === "#backlinks"}>
348 <ErrorBoundary
349 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
350 >
351 <Suspense
352 fallback={
353 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" />
354 }
355 >
356 <Backlinks target={did} />
357 </Suspense>
358 </ErrorBoundary>
359 </Show>
360 <Show when={location.hash === "#blobs"}>
361 <ErrorBoundary
362 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
363 >
364 <Suspense
365 fallback={
366 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" />
367 }
368 >
369 <BlobView pds={pds!} repo={did} />
370 </Suspense>
371 </ErrorBoundary>
372 </Show>
373 <Show when={nsids() && (!location.hash || location.hash === "#collections")}>
374 <Show when={showFilter()}>
375 <TextInput
376 name="filter"
377 placeholder="Filter collections"
378 onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())}
379 class="grow"
380 ref={(node) => {
381 onMount(() => node.focus());
382 }}
383 />
384 </Show>
385 <div
386 class="flex flex-col overflow-hidden text-sm"
387 classList={{ "-mt-1": !showFilter() }}
388 >
389 <For
390 each={Object.keys(nsids() ?? {}).filter((authority) =>
391 filter() ?
392 authority.includes(filter()!) ||
393 nsids()?.[authority].nsids.some((nsid) =>
394 `${authority}.${nsid}`.includes(filter()!),
395 )
396 : true,
397 )}
398 >
399 {(authority) => {
400 const reversedDomain = authority.split(".").reverse().join(".");
401 const [faviconLoaded, setFaviconLoaded] = createSignal(false);
402
403 return (
404 <div class="dark:hover:bg-dark-200 flex items-start gap-2 rounded-lg p-1 hover:bg-neutral-200">
405 <div class="flex h-5 w-4 shrink-0 items-center justify-center">
406 <Show when={!faviconLoaded()}>
407 <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" />
408 </Show>
409 <img
410 src={
411 ["bsky.app", "bsky.chat"].includes(reversedDomain) ?
412 "https://web-cdn.bsky.app/static/apple-touch-icon.png"
413 : `https://${reversedDomain}/favicon.ico`
414 }
415 alt={`${reversedDomain} favicon`}
416 class="h-4 w-4"
417 classList={{ hidden: !faviconLoaded() }}
418 onLoad={() => setFaviconLoaded(true)}
419 onError={() => setFaviconLoaded(false)}
420 />
421 </div>
422 <div class="flex flex-1 flex-col">
423 <For
424 each={nsids()?.[authority].nsids.filter((nsid) =>
425 filter() ? `${authority}.${nsid}`.includes(filter()!) : true,
426 )}
427 >
428 {(nsid) => (
429 <A
430 href={`/at://${did}/${authority}.${nsid}`}
431 class="hover:underline active:underline"
432 >
433 <span>{authority}</span>
434 <span class="text-neutral-500 dark:text-neutral-400">.{nsid}</span>
435 </A>
436 )}
437 </For>
438 </div>
439 </div>
440 );
441 }}
442 </For>
443 </div>
444 </Show>
445 <Show when={location.hash === "#identity" || (error() && !location.hash)}>
446 <Show when={didDoc()}>
447 {(didDocument) => (
448 <div class="flex flex-col gap-2 wrap-anywhere">
449 {/* ID Section */}
450 <div>
451 <div class="flex items-center gap-1">
452 <div class="iconify lucide--id-card" />
453 <p class="font-semibold">ID</p>
454 </div>
455 <div class="text-sm">{didDocument().id}</div>
456 </div>
457
458 {/* Aliases Section */}
459 <div>
460 <div class="flex items-center gap-1">
461 <div class="iconify lucide--at-sign" />
462 <p class="font-semibold">Aliases</p>
463 </div>
464 <div class="flex flex-col gap-0.5">
465 <For each={didDocument().alsoKnownAs}>
466 {(alias) => (
467 <div class="flex items-center gap-1 text-sm">
468 <span>{alias}</span>
469 <Show when={alias.startsWith("at://")}>
470 <Tooltip
471 text={
472 validHandles[alias] === true ? "Valid handle"
473 : validHandles[alias] === undefined ?
474 "Validating"
475 : "Invalid handle"
476 }
477 >
478 <span
479 classList={{
480 "iconify lucide--circle-check text-green-600 dark:text-green-400":
481 validHandles[alias] === true,
482 "iconify lucide--circle-x text-red-500 dark:text-red-400":
483 validHandles[alias] === false,
484 "iconify lucide--loader-circle animate-spin":
485 validHandles[alias] === undefined,
486 }}
487 ></span>
488 </Tooltip>
489 </Show>
490 </div>
491 )}
492 </For>
493 </div>
494 </div>
495
496 {/* Services Section */}
497 <div>
498 <div class="flex items-center gap-1">
499 <div class="iconify lucide--hard-drive" />
500 <p class="font-semibold">Services</p>
501 </div>
502 <div class="flex flex-col gap-0.5">
503 <For each={didDocument().service}>
504 {(service) => (
505 <div class="text-sm">
506 <div class="font-medium text-neutral-700 dark:text-neutral-300">
507 #{service.id.split("#")[1]}
508 </div>
509 <a
510 class="underline hover:text-blue-400"
511 href={service.serviceEndpoint.toString()}
512 target="_blank"
513 rel="noopener"
514 >
515 {service.serviceEndpoint.toString()}
516 </a>
517 </div>
518 )}
519 </For>
520 </div>
521 </div>
522
523 {/* Verification Methods Section */}
524 <div>
525 <div class="flex items-center gap-1">
526 <div class="iconify lucide--shield-check" />
527 <p class="font-semibold">Verification Methods</p>
528 </div>
529 <div class="flex flex-col gap-0.5">
530 <For each={didDocument().verificationMethod}>
531 {(verif) => (
532 <Show when={verif.publicKeyMultibase}>
533 {(key) => (
534 <div class="text-sm">
535 <div class="flex items-baseline gap-1">
536 <span class="font-medium text-neutral-700 dark:text-neutral-300">
537 #{verif.id.split("#")[1]}
538 </span>
539 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300">
540 <ErrorBoundary fallback={<>unknown</>}>
541 {parsePublicMultikey(key()).type}
542 </ErrorBoundary>
543 </span>
544 </div>
545 <div class="font-mono break-all">{key()}</div>
546 </div>
547 )}
548 </Show>
549 )}
550 </For>
551 </div>
552 </div>
553
554 {/* Rotation Keys Section */}
555 <Show when={rotationKeys().length > 0}>
556 <div>
557 <div class="flex items-center gap-1">
558 <div class="iconify lucide--key-round" />
559 <p class="font-semibold">Rotation Keys</p>
560 </div>
561 <div class="flex flex-col gap-0.5">
562 <For each={rotationKeys()}>
563 {(key) => (
564 <div class="text-sm">
565 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300">
566 {parseDidKey(key).type}
567 </span>
568 <div class="font-mono break-all">{key.replace("did:key:", "")}</div>
569 </div>
570 )}
571 </For>
572 </div>
573 </div>
574 </Show>
575 </div>
576 )}
577 </Show>
578 </Show>
579 </div>
580 </div>
581 </Show>
582 );
583};