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: Uint8Array[] = [];
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 <For
408 each={Object.keys(nsids() ?? {}).filter((authority) =>
409 filter() ?
410 authority.includes(filter()!) ||
411 nsids()?.[authority].nsids.some((nsid) =>
412 `${authority}.${nsid}`.includes(filter()!),
413 )
414 : true,
415 )}
416 >
417 {(authority) => {
418 const reversedDomain = authority.split(".").reverse().join(".");
419 const [faviconLoaded, setFaviconLoaded] = createSignal(false);
420
421 const isHighlighted = () => location.hash === `#collections:${authority}`;
422
423 return (
424 <div
425 id={`collection-${authority}`}
426 class="group flex items-start gap-2 rounded-lg p-1 transition-colors"
427 classList={{
428 "dark:hover:bg-dark-200 hover:bg-neutral-200": !isHighlighted(),
429 "bg-blue-100 dark:bg-blue-500/25": isHighlighted(),
430 }}
431 >
432 <a
433 href={`#collections:${authority}`}
434 class="relative flex h-5 w-4 shrink-0 items-center justify-center hover:opacity-70"
435 >
436 <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">
437 <span class="iconify lucide--link absolute -left-2 w-7"></span>
438 </span>
439 <Show when={!faviconLoaded()}>
440 <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" />
441 </Show>
442 <img
443 src={
444 ["bsky.app", "bsky.chat"].includes(reversedDomain) ?
445 "https://web-cdn.bsky.app/static/apple-touch-icon.png"
446 : `https://${reversedDomain}/favicon.ico`
447 }
448 alt={`${reversedDomain} favicon`}
449 class="h-4 w-4"
450 classList={{ hidden: !faviconLoaded() }}
451 onLoad={() => setFaviconLoaded(true)}
452 onError={() => setFaviconLoaded(false)}
453 />
454 </a>
455 <div class="flex flex-1 flex-col">
456 <For
457 each={nsids()?.[authority].nsids.filter((nsid) =>
458 filter() ? `${authority}.${nsid}`.includes(filter()!) : true,
459 )}
460 >
461 {(nsid) => (
462 <A
463 href={`/at://${did}/${authority}.${nsid}`}
464 class="hover:underline active:underline"
465 >
466 <span>{authority}</span>
467 <span class="text-neutral-500 dark:text-neutral-400">.{nsid}</span>
468 </A>
469 )}
470 </For>
471 </div>
472 </div>
473 );
474 }}
475 </For>
476 </div>
477 </Show>
478 <Show when={location.hash === "#identity" || (error() && !location.hash)}>
479 <Show when={didDoc()}>
480 {(didDocument) => (
481 <div class="flex flex-col gap-3 wrap-anywhere">
482 {/* ID Section */}
483 <div>
484 <div class="flex items-center gap-1">
485 <div class="iconify lucide--id-card" />
486 <p class="font-semibold">ID</p>
487 </div>
488 <div class="text-sm">{didDocument().id}</div>
489 </div>
490
491 {/* Aliases Section */}
492 <div>
493 <div class="flex items-center gap-1">
494 <div class="iconify lucide--at-sign" />
495 <p class="font-semibold">Aliases</p>
496 </div>
497 <div class="flex flex-col gap-0.5">
498 <For each={didDocument().alsoKnownAs}>
499 {(alias) => (
500 <div class="flex items-center gap-1 text-sm">
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--circle-check text-green-600 dark:text-green-400":
514 validHandles[alias] === true,
515 "iconify lucide--circle-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 </div>
528
529 {/* Services Section */}
530 <div>
531 <div class="flex items-center gap-1">
532 <div class="iconify lucide--hard-drive" />
533 <p class="font-semibold">Services</p>
534 </div>
535 <div class="flex flex-col gap-0.5">
536 <For each={didDocument().service}>
537 {(service) => (
538 <div class="text-sm">
539 <div class="font-medium text-neutral-700 dark:text-neutral-300">
540 #{service.id.split("#")[1]}
541 </div>
542 <a
543 class="underline hover:text-blue-400"
544 href={service.serviceEndpoint.toString()}
545 target="_blank"
546 rel="noopener"
547 >
548 {service.serviceEndpoint.toString()}
549 </a>
550 </div>
551 )}
552 </For>
553 </div>
554 </div>
555
556 {/* Verification Methods Section */}
557 <div>
558 <div class="flex items-center gap-1">
559 <div class="iconify lucide--shield-check" />
560 <p class="font-semibold">Verification Methods</p>
561 </div>
562 <div class="flex flex-col gap-0.5">
563 <For each={didDocument().verificationMethod}>
564 {(verif) => (
565 <Show when={verif.publicKeyMultibase}>
566 {(key) => (
567 <div class="text-sm">
568 <div class="flex items-baseline gap-1">
569 <span class="font-medium text-neutral-700 dark:text-neutral-300">
570 #{verif.id.split("#")[1]}
571 </span>
572 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300">
573 {detectKeyType(key())}
574 </span>
575 </div>
576 <div class="font-mono break-all">{key()}</div>
577 </div>
578 )}
579 </Show>
580 )}
581 </For>
582 </div>
583 </div>
584
585 {/* Rotation Keys Section */}
586 <Show when={rotationKeys().length > 0}>
587 <div>
588 <div class="flex items-center gap-1">
589 <div class="iconify lucide--key-round" />
590 <p class="font-semibold">Rotation Keys</p>
591 </div>
592 <div class="flex flex-col gap-0.5">
593 <For each={rotationKeys()}>
594 {(key) => (
595 <div class="text-sm">
596 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300">
597 {detectDidKeyType(key)}
598 </span>
599 <div class="font-mono break-all">{key.replace("did:key:", "")}</div>
600 </div>
601 )}
602 </For>
603 </div>
604 </div>
605 </Show>
606 </div>
607 )}
608 </Show>
609 </Show>
610 </div>
611 </div>
612 </Show>
613 );
614};