atmosphere explorer pds.ls
tool typescript atproto
438
fork

Configure Feed

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

New routing (#50)

* new

* test

* progress

* remove resolvePDS

* fix routes

* latch utility

* pds context

* move files

* fix pds cache

* reorganize views

* fix loading spin collection

authored by juliet.paris and committed by

GitHub 360d9e92 ea5a0088

+1287 -1209
+1 -1
src/auth/oauth-config.ts
··· 1 1 import { LocalActorResolver } from "@atcute/identity-resolver"; 2 2 import { configureOAuth } from "@atcute/oauth-browser-client"; 3 - import { didDocumentResolver, handleResolver } from "../utils/api"; 3 + import { didDocumentResolver, handleResolver } from "../lib/api"; 4 4 5 5 const reactiveDidDocumentResolver = { 6 6 resolve: async (did: string) => didDocumentResolver().resolve(did as any),
+1 -1
src/auth/session-manager.ts
··· 6 6 OAuthUserAgent, 7 7 type Session, 8 8 } from "@atcute/oauth-browser-client"; 9 - import { resolveDidDoc } from "../utils/api"; 9 + import { resolveDidDoc } from "../lib/api"; 10 10 import { Sessions, setAgent, setSessions } from "./state"; 11 11 12 12 export const saveSessionToStorage = (sessions: Sessions) => {
+1 -1
src/components/backlinks.tsx
··· 1 1 import * as TID from "@atcute/tid"; 2 2 import { createResource, createSignal, For, onMount, Show } from "solid-js"; 3 - import { getAllBacklinks, getRecordBacklinks, LinksWithRecords } from "../utils/api.js"; 3 + import { getAllBacklinks, getRecordBacklinks, LinksWithRecords } from "../lib/api.js"; 4 4 import { localDateFromTimestamp } from "../utils/date.js"; 5 5 import { Button } from "./button.jsx"; 6 6 import { Favicon } from "./favicon.jsx";
+1 -1
src/components/create/handle-input.tsx
··· 1 1 import { Handle } from "@atcute/lexicons"; 2 2 import { createSignal, Show } from "solid-js"; 3 - import { resolveHandle } from "../../utils/api"; 3 + import { resolveHandle } from "../../lib/api"; 4 4 import { Button } from "../button.jsx"; 5 5 import { TextInput } from "../text-input.jsx"; 6 6 import { editorInstance } from "./state";
+1 -1
src/components/hover-card/did.tsx
··· 1 1 import { getPdsEndpoint, type DidDocument } from "@atcute/identity"; 2 2 import { createSignal, Show } from "solid-js"; 3 - import { resolveDidDoc } from "../../utils/api"; 3 + import { resolveDidDoc } from "../../lib/api"; 4 4 import HoverCard from "./base"; 5 5 6 6 interface DidHoverCardProps {
+1 -1
src/components/hover-card/record.tsx
··· 1 1 import { Client, simpleFetchHandler } from "@atcute/client"; 2 2 import { ActorIdentifier } from "@atcute/lexicons"; 3 3 import { createSignal, Show } from "solid-js"; 4 - import { getPDS } from "../../utils/api"; 4 + import { getPDS } from "../../lib/api"; 5 5 import { JSONValue } from "../json"; 6 6 import HoverCard from "./base"; 7 7
+8 -6
src/components/json.tsx
··· 12 12 useContext, 13 13 } from "solid-js"; 14 14 import { Portal } from "solid-js/web"; 15 - import { resolveLexiconAuthority } from "../utils/api"; 15 + import { resolveLexiconAuthority } from "../lib/api"; 16 16 import { formatFileSize } from "../utils/format"; 17 17 import { hideMedia } from "../views/settings"; 18 18 import DidHoverCard from "./hover-card/did"; 19 19 import RecordHoverCard from "./hover-card/record"; 20 - import { pds } from "./navbar"; 21 20 import { addNotification, removeNotification } from "./notification"; 22 21 import VideoPlayer from "./video-player"; 23 22 24 23 interface JSONContext { 25 24 repo: string; 25 + pds?: string; 26 26 truncate?: boolean; 27 27 parentIsBlob?: boolean; 28 28 newTab?: boolean; ··· 104 104 class="text-blue-500 hover:underline active:underline dark:text-blue-400" 105 105 rel="noopener" 106 106 target="_blank" 107 - href={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${params.repo}&cid=${part}`} 107 + href={`${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${params.repo}&cid=${part}`} 108 108 > 109 109 {part} 110 110 </A> ··· 312 312 313 313 const blob: AtBlob = props.data as any; 314 314 const canShowMedia = () => 315 - pds() && 315 + ctx.pds && 316 316 !ctx.hideBlobs && 317 317 (blob.mimeType.startsWith("image/") || 318 318 blob.mimeType === "video/mp4" || ··· 341 341 const [imageUrl] = createResource( 342 342 () => (blob.mimeType.startsWith("image/") ? blob.ref.$link : null), 343 343 async (cid) => { 344 - const url = `https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`; 344 + const url = `${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`; 345 345 346 346 await new Promise<void>((resolve) => { 347 347 const img = new Image(); ··· 397 397 <Show when={blob.mimeType.startsWith("audio/")}> 398 398 <audio class="my-0.5 max-w-96" controls> 399 399 <source 400 - src={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${blob.ref.$link}`} 400 + src={`${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${blob.ref.$link}`} 401 401 type={blob.mimeType === "audio/x-flac" ? "audio/flac" : blob.mimeType} 402 402 /> 403 403 </audio> ··· 464 464 export const JSONValue = (props: { 465 465 data: JSONType; 466 466 repo: string; 467 + pds?: string; 467 468 truncate?: boolean; 468 469 newTab?: boolean; 469 470 hideBlobs?: boolean; ··· 473 474 <JSONCtx.Provider 474 475 value={{ 475 476 repo: props.repo, 477 + pds: props.pds, 476 478 truncate: props.truncate, 477 479 newTab: props.newTab, 478 480 hideBlobs: props.hideBlobs,
+13
src/components/lazy-tab.tsx
··· 1 + import { ErrorBoundary, JSX, Suspense } from "solid-js"; 2 + 3 + export const LazyTab = (props: { children: JSX.Element }) => ( 4 + <ErrorBoundary fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}> 5 + <Suspense 6 + fallback={ 7 + <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 8 + } 9 + > 10 + {props.children} 11 + </Suspense> 12 + </ErrorBoundary> 13 + );
+1 -1
src/components/lexicon-schema.tsx
··· 2 2 import { AtprotoDid } from "@atcute/lexicons/syntax"; 3 3 import { A, useLocation, useNavigate } from "@solidjs/router"; 4 4 import { createEffect, For, Show } from "solid-js"; 5 - import { resolveLexiconAuthority } from "../utils/api.js"; 5 + import { resolveLexiconAuthority } from "../lib/api.js"; 6 6 import Tooltip from "./tooltip.jsx"; 7 7 8 8 // Style constants
+1 -1
src/components/navbar.tsx
··· 2 2 import { A, Params } from "@solidjs/router"; 3 3 import { createEffect, createMemo, createSignal, JSX, Match, Show, Switch } from "solid-js"; 4 4 import { canHover } from "../layout"; 5 - import { didDocCache } from "../utils/api"; 5 + import { didDocCache } from "../lib/api"; 6 6 import { addToClipboard } from "../utils/copy"; 7 7 import { localDateFromTimestamp } from "../utils/date"; 8 8 import Tooltip from "./tooltip";
+20
src/components/nested-layout.tsx
··· 1 + import { JSX, Show, Suspense } from "solid-js"; 2 + import { Spinner } from "./spinner.jsx"; 3 + 4 + export const NestedLayout = (props: { 5 + key: string | undefined; 6 + hasChild: boolean; 7 + view: () => JSX.Element; 8 + children: JSX.Element; 9 + }) => ( 10 + <Show keyed when={props.key}> 11 + {(_) => ( 12 + <> 13 + <Show when={props.hasChild}> 14 + <Suspense fallback={<Spinner />}>{props.children}</Suspense> 15 + </Show> 16 + {props.view()} 17 + </> 18 + )} 19 + </Show> 20 + );
+3 -3
src/components/search.tsx
··· 11 11 Show, 12 12 } from "solid-js"; 13 13 import { canHover } from "../layout"; 14 - import { resolveLexiconAuthority, resolveLexiconAuthorityDirect } from "../utils/api"; 15 - import { appHandleLink, appList, AppUrl } from "../utils/app-urls"; 16 - import { createDebouncedValue } from "../utils/hooks/debounced"; 14 + import { resolveLexiconAuthority, resolveLexiconAuthorityDirect } from "../lib/api"; 15 + import { appHandleLink, appList, AppUrl } from "../lib/app-urls"; 16 + import { createDebouncedValue } from "../lib/debounced"; 17 17 import { Button } from "./button"; 18 18 import { Modal } from "./modal"; 19 19
+3
src/components/spinner.tsx
··· 1 + export const Spinner = () => ( 2 + <span class="iconify lucide--loader-circle mt-3 animate-spin text-xl"></span> 3 + );
+3 -2
src/components/video-player.tsx
··· 1 1 import { onCleanup, onMount } from "solid-js"; 2 - import { pds } from "./navbar"; 2 + import { useRepo } from "../lib/repo-context.jsx"; 3 3 4 4 export interface VideoPlayerProps { 5 5 did: string; ··· 7 7 } 8 8 9 9 const VideoPlayer = (props: VideoPlayerProps) => { 10 + const repo = useRepo(); 10 11 let video!: HTMLVideoElement; 11 12 let objectUrl: string | undefined; 12 13 13 14 onMount(async () => { 14 15 // thanks bf <3 15 16 const res = await fetch( 16 - `https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${props.did}&cid=${props.cid}`, 17 + `${repo.pds()}/xrpc/com.atproto.sync.getBlob?did=${props.did}&cid=${props.cid}`, 17 18 ); 18 19 if (!res.ok) throw new Error(res.statusText); 19 20 const blob = await res.blob();
+13 -7
src/index.tsx
··· 6 6 import { ExploreToolView } from "./views/car/explore.tsx"; 7 7 import { CarView } from "./views/car/index.tsx"; 8 8 import { UnpackToolView } from "./views/car/unpack.tsx"; 9 - import { CollectionView } from "./views/collection.tsx"; 9 + import { CollectionLayout } from "./views/collection.tsx"; 10 10 import { Home } from "./views/home.tsx"; 11 11 import { LabelView } from "./views/labels.tsx"; 12 - import { PdsView } from "./views/pds.tsx"; 12 + import { PdsLayout } from "./views/pds.tsx"; 13 13 import { RecordView } from "./views/record.tsx"; 14 - import { RepoView } from "./views/repo.tsx"; 14 + import { RepoLayout, repoPreload } from "./views/repo/index.tsx"; 15 15 import { Settings } from "./views/settings.tsx"; 16 16 import { StreamView } from "./views/stream"; 17 17 ··· 25 25 <Route path="/car/explore" component={ExploreToolView} /> 26 26 <Route path="/car/unpack" component={UnpackToolView} /> 27 27 <Route path="/settings" component={Settings} /> 28 - <Route path="/:pds" component={PdsView} /> 29 - <Route path="/:pds/:repo" component={RepoView} /> 30 - <Route path="/:pds/:repo/:collection" component={CollectionView} /> 31 - <Route path="/:pds/:repo/:collection/:rkey" component={RecordView} /> 28 + <Route path="/:pds" component={PdsLayout}> 29 + <Route path="/" /> 30 + <Route path="/:repo" component={RepoLayout} preload={repoPreload}> 31 + <Route path="/" /> 32 + <Route path="/:collection" component={CollectionLayout}> 33 + <Route path="/" /> 34 + <Route path="/:rkey" component={RecordView} /> 35 + </Route> 36 + </Route> 37 + </Route> 32 38 </Router> 33 39 ), 34 40 document.getElementById("root") as HTMLElement,
+16 -23
src/layout.tsx
··· 1 - import { Handle } from "@atcute/lexicons"; 2 1 import { A, RouteSectionProps, useLocation, useNavigate } from "@solidjs/router"; 3 - import { createEffect, ErrorBoundary, onCleanup, onMount, Show, Suspense } from "solid-js"; 2 + import { createEffect, ErrorBoundary, on, onCleanup, onMount, Show, Suspense } from "solid-js"; 4 3 import { AccountManager } from "./auth/account.jsx"; 5 4 import { agent } from "./auth/state.js"; 6 5 import { RecordEditor } from "./components/create"; ··· 9 8 import { NotificationContainer } from "./components/notification.jsx"; 10 9 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 11 10 import { Search, SearchButton } from "./components/search.jsx"; 11 + import { Spinner } from "./components/spinner.jsx"; 12 12 import { themeEvent } from "./components/theme.jsx"; 13 - import { resolveHandle } from "./utils/api.js"; 14 13 import { plcDirectory } from "./views/settings.jsx"; 15 14 16 15 export const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches; ··· 33 32 else if (location.search.includes("hrt=false")) localStorage.setItem("hrt", "false"); 34 33 if (location.search.includes("sailor=true")) localStorage.setItem("sailor", "true"); 35 34 else if (location.search.includes("sailor=false")) localStorage.setItem("sailor", "false"); 36 - 37 - createEffect(async () => { 38 - if (props.params.repo && !props.params.repo.startsWith("did:")) { 39 - const did = await resolveHandle(props.params.repo as Handle); 40 - navigate(location.pathname.replace(props.params.repo, did), { replace: true }); 41 - } 42 - }); 43 35 44 36 onMount(() => { 45 37 window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", themeEvent); ··· 168 160 <Show when={props.params.pds}> 169 161 <NavBar params={props.params} /> 170 162 </Show> 171 - <Show keyed when={location.pathname}> 172 - <ErrorBoundary 173 - fallback={(err) => <div class="mt-3 wrap-anywhere">Error: {err.message}</div>} 174 - > 175 - <Suspense 176 - fallback={ 177 - <span class="iconify lucide--loader-circle mt-3 animate-spin text-xl"></span> 178 - } 179 - > 180 - {props.children} 181 - </Suspense> 182 - </ErrorBoundary> 183 - </Show> 163 + <ErrorBoundary 164 + fallback={(err, reset) => { 165 + createEffect( 166 + on( 167 + () => location.pathname, 168 + () => reset(), 169 + { defer: true }, 170 + ), 171 + ); 172 + return <div class="mt-3 wrap-anywhere">Error: {err.message}</div>; 173 + }} 174 + > 175 + <Suspense fallback={<Spinner />}>{props.children}</Suspense> 176 + </ErrorBoundary> 184 177 </div> 185 178 <NotificationContainer /> 186 179 <PermissionPromptContainer />
+5
src/lib/create-latch.ts
··· 1 + import { createMemo } from "solid-js"; 2 + 3 + /** Returns a memo that latches to `true` once `ready()` is true, and never reverts. */ 4 + export const createLatch = (ready: () => boolean) => 5 + createMemo<true | undefined>((prev) => prev || (ready() ? true : undefined));
+21
src/lib/repo-context.tsx
··· 1 + import { Client } from "@atcute/client"; 2 + import { DidDocument } from "@atcute/identity"; 3 + import { Accessor, createContext, useContext } from "solid-js"; 4 + 5 + export interface RepoContextValue { 6 + did: Accessor<string>; 7 + pds: Accessor<string | undefined>; 8 + rpc: Accessor<Client | undefined>; 9 + didDoc: Accessor<DidDocument | undefined>; 10 + error: Accessor<string | undefined>; 11 + } 12 + 13 + const RepoContext = createContext<RepoContextValue>(); 14 + 15 + export const RepoProvider = RepoContext.Provider; 16 + 17 + export const useRepo = () => { 18 + const ctx = useContext(RepoContext); 19 + if (!ctx) throw new Error("useRepo must be used within RepoProvider"); 20 + return ctx; 21 + };
-15
src/utils/api.ts src/lib/api.ts
··· 18 18 import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax"; 19 19 import { createMemo } from "solid-js"; 20 20 import { createStore } from "solid-js/store"; 21 - import { setPDS } from "../components/navbar"; 22 21 import { plcDirectory } from "../views/settings"; 23 22 24 23 const proxyFetch = (rewrite: (url: URL) => string): typeof fetch => { ··· 141 140 return true; 142 141 }; 143 142 144 - const resolvePDS = async (did: string) => { 145 - try { 146 - setPDS(undefined); 147 - const pds = await getPDS(did); 148 - if (!pds) throw new Error("No PDS found"); 149 - setPDS(pds.replace("https://", "").replace("http://", "")); 150 - return pds; 151 - } catch (err) { 152 - setPDS("Missing PDS"); 153 - throw err; 154 - } 155 - }; 156 - 157 143 const resolveLexiconAuthority = async (nsid: Nsid) => { 158 144 return await authorityResolver.resolve(nsid); 159 145 }; ··· 283 269 resolveLexiconAuthority, 284 270 resolveLexiconAuthorityDirect, 285 271 resolveLexiconSchema, 286 - resolvePDS, 287 272 validateHandle, 288 273 type LinkData, 289 274 type LinksWithRecords,
src/utils/app-urls.ts src/lib/app-urls.ts
src/utils/hooks/debounced.ts src/lib/debounced.ts
src/utils/key.ts src/lib/key.ts
src/utils/keyboard.ts src/lib/keyboard.ts
src/utils/plc-logs.ts src/lib/plc-logs.ts
-25
src/utils/route-cache.ts
··· 1 - import { createStore } from "solid-js/store"; 2 - 3 - export interface CollectionCacheEntry { 4 - records: unknown[]; 5 - cursor: string | undefined; 6 - scrollY: number; 7 - reverse: boolean; 8 - limit: number; 9 - } 10 - 11 - type RouteCache = Record<string, CollectionCacheEntry>; 12 - 13 - const [routeCache, setRouteCache] = createStore<RouteCache>({}); 14 - 15 - export const getCollectionCache = (key: string): CollectionCacheEntry | undefined => { 16 - return routeCache[key]; 17 - }; 18 - 19 - export const setCollectionCache = (key: string, entry: CollectionCacheEntry): void => { 20 - setRouteCache(key, entry); 21 - }; 22 - 23 - export const clearCollectionCache = (key: string): void => { 24 - setRouteCache(key, undefined!); 25 - };
src/utils/templates.ts src/lib/templates.ts
src/utils/types/lexicons.ts src/lib/types/lexicons.ts
+2 -2
src/views/car/explore.tsx
··· 20 20 import HoverCard from "../../components/hover-card/base"; 21 21 import { JSONValue } from "../../components/json.jsx"; 22 22 import { TextInput } from "../../components/text-input.jsx"; 23 - import { didDocCache, resolveDidDoc } from "../../utils/api.js"; 23 + import { didDocCache, resolveDidDoc } from "../../lib/api.js"; 24 + import { createDebouncedValue } from "../../lib/debounced.js"; 24 25 import { localDateFromTimestamp } from "../../utils/date.js"; 25 - import { createDebouncedValue } from "../../utils/hooks/debounced.js"; 26 26 import { createDropHandler, createFileChangeHandler, handleDragOver } from "./file-handlers.js"; 27 27 import { 28 28 type Archive,
+45 -68
src/views/collection.tsx
··· 1 1 import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto"; 2 - import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { Client } from "@atcute/client"; 3 3 import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons"; 4 4 import * as TID from "@atcute/tid"; 5 - import { A, useBeforeLeave, useParams, useSearchParams } from "@solidjs/router"; 5 + import { A, type RouteSectionProps, useParams, useSearchParams } from "@solidjs/router"; 6 6 import { createMemo, createResource, createSignal, For, onMount, Show } from "solid-js"; 7 7 import { createStore } from "solid-js/store"; 8 8 import { agent } from "../auth/state"; ··· 10 10 import HoverCard from "../components/hover-card/base"; 11 11 import { JSONType, JSONValue } from "../components/json.jsx"; 12 12 import { Modal } from "../components/modal.jsx"; 13 + import { NestedLayout } from "../components/nested-layout.jsx"; 13 14 import { addNotification, removeNotification } from "../components/notification.jsx"; 14 15 import { PermissionButton } from "../components/permission-button.jsx"; 16 + import { Spinner } from "../components/spinner.jsx"; 15 17 import Tooltip from "../components/tooltip.jsx"; 16 18 import { canHover } from "../layout.jsx"; 17 - import { resolvePDS } from "../utils/api.js"; 19 + import { useRepo } from "../lib/repo-context.jsx"; 20 + import { createLatch } from "../lib/create-latch.js"; 18 21 import { localDateFromTimestamp } from "../utils/date.js"; 19 - import { useFilterShortcut } from "../utils/keyboard.js"; 20 - import { 21 - clearCollectionCache, 22 - getCollectionCache, 23 - setCollectionCache, 24 - } from "../utils/route-cache.js"; 22 + import { useFilterShortcut } from "../lib/keyboard.js"; 25 23 26 24 interface AtprotoRecord { 27 25 rkey: string; ··· 63 61 ); 64 62 }; 65 63 64 + export const CollectionLayout = (props: RouteSectionProps) => { 65 + const params = useParams(); 66 + const hasChild = () => !!params.rkey; 67 + return ( 68 + <NestedLayout 69 + key={`${params.repo}/${params.collection}`} 70 + hasChild={hasChild()} 71 + view={() => <CollectionView />} 72 + > 73 + {props.children} 74 + </NestedLayout> 75 + ); 76 + }; 77 + 66 78 const CollectionView = () => { 79 + const repo = useRepo(); 67 80 const params = useParams(); 81 + const hidden = () => !!params.rkey; 68 82 const [searchParams, setSearchParams] = useSearchParams(); 69 83 const [cursor, setCursor] = createSignal<string>(); 70 84 const [records, setRecords] = createStore<AtprotoRecord[]>([]); ··· 80 94 }; 81 95 const [recreate, setRecreate] = createSignal(false); 82 96 const [openDelete, setOpenDelete] = createSignal(false); 83 - const [restoredFromCache, setRestoredFromCache] = createSignal(false); 84 - const did = params.repo; 85 - let pds: string; 86 - let rpc: Client; 97 + const [isLoadingMore, setIsLoadingMore] = createSignal(false); 98 + const did = repo.did(); 87 99 let filterInputRef: HTMLInputElement | undefined; 88 100 89 - const cacheKey = () => `${params.pds}/${params.repo}/${params.collection}`; 90 - 91 101 onMount(() => { 92 - const cached = getCollectionCache(cacheKey()); 93 - if (cached) { 94 - setRecords(cached.records as AtprotoRecord[]); 95 - setCursor(cached.cursor); 96 - setReverse(cached.reverse); 97 - setSearchParams({ 98 - reverse: cached.reverse ? "true" : undefined, 99 - limit: cached.limit !== DEFAULT_LIMIT ? cached.limit.toString() : undefined, 100 - }); 101 - setRestoredFromCache(true); 102 - requestAnimationFrame(() => { 103 - window.scrollTo(0, cached.scrollY); 104 - }); 105 - } 106 - 107 102 useFilterShortcut(() => filterInputRef); 108 103 }); 109 104 110 - useBeforeLeave((e) => { 111 - const recordPathPrefix = `/at://${did}/${params.collection}/`; 112 - const isNavigatingToRecord = typeof e.to === "string" && e.to.startsWith(recordPathPrefix); 113 - 114 - if (isNavigatingToRecord && records.length > 0) { 115 - setCollectionCache(cacheKey(), { 116 - records: [...records], 117 - cursor: cursor(), 118 - scrollY: window.scrollY, 119 - reverse: reverse(), 120 - limit: limit(), 121 - }); 122 - } else { 123 - clearCollectionCache(cacheKey()); 124 - } 125 - }); 126 - 127 105 const fetchRecords = async () => { 128 - if (restoredFromCache() && records.length > 0 && !cursor()) { 129 - setRestoredFromCache(false); 130 - return records; 131 - } 132 - if (restoredFromCache()) setRestoredFromCache(false); 133 - 134 - const isLoadMore = cursor() !== undefined; 106 + const rpc = repo.rpc()!; 107 + const collection = params.collection!; 108 + const isLoadMore = isLoadingMore(); 109 + setIsLoadingMore(false); 135 110 136 - if (!pds) pds = await resolvePDS(did!); 137 - if (!rpc) rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 138 111 const res = await rpc.get("com.atproto.repo.listRecords", { 139 112 params: { 140 113 repo: did as ActorIdentifier, 141 - collection: params.collection as `${string}.${string}.${string}`, 114 + collection: collection as `${string}.${string}.${string}`, 142 115 limit: limit(), 143 116 cursor: cursor(), 144 117 reverse: reverse(), ··· 161 134 return res.data.records; 162 135 }; 163 136 164 - const [response, { refetch }] = createResource(fetchRecords); 137 + const shouldFetch = createLatch(() => !hidden() && !!repo.rpc()); 138 + 139 + const [response, { refetch }] = createResource(shouldFetch, fetchRecords); 165 140 166 141 const filteredRecords = createMemo(() => 167 142 records.filter((rec) => ··· 192 167 }); 193 168 194 169 const BATCHSIZE = 200; 195 - rpc = new Client({ handler: agent()! }); 170 + const authRpc = new Client({ handler: agent()! }); 196 171 for (let i = 0; i < writes.length; i += BATCHSIZE) { 197 - await rpc.post("com.atproto.repo.applyWrites", { 172 + await authRpc.post("com.atproto.repo.applyWrites", { 198 173 input: { 199 174 repo: agent()!.sub, 200 175 writes: writes.slice(i, i + BATCHSIZE), ··· 211 186 setCursor(undefined); 212 187 setOpenDelete(false); 213 188 setRecreate(false); 214 - clearCollectionCache(cacheKey()); 215 189 refetch(); 216 190 }; 217 191 ··· 239 213 true, 240 214 ); 241 215 242 - document.title = `${params.collection} - PDSls`; 216 + if (!hidden()) document.title = `${params.collection} - PDSls`; 243 217 244 218 return ( 245 219 <> 246 - <Show when={records.length || response()}> 220 + <Show when={!hidden() && !records.length && (response.state === "unresolved" || response.loading)}> 221 + <Spinner /> 222 + </Show> 223 + <Show when={!hidden() && (records.length || response.state === "ready")}> 247 224 <div class="flex w-full flex-col items-center"> 248 225 {/* Tab bar */} 249 226 <div class="mb-2 flex min-h-7 w-full items-center justify-between px-2 text-sm sm:text-base"> ··· 423 400 setReverse(newReverse); 424 401 setSearchParams({ reverse: newReverse ? "true" : undefined }); 425 402 setCursor(undefined); 426 - setRestoredFromCache(false); 427 - clearCollectionCache(cacheKey()); 403 + setRecords([]); 428 404 refetch(); 429 405 }} 430 406 classList={{ ··· 451 427 <div class="flex w-20 items-center justify-end"> 452 428 <Show when={cursor()}> 453 429 <Button 454 - onClick={() => refetch()} 430 + onClick={() => { 431 + setIsLoadingMore(true); 432 + refetch(); 433 + }} 455 434 disabled={response.loading} 456 435 classList={{ "w-20 h-7.5 justify-center": true }} 457 436 > ··· 473 452 </> 474 453 ); 475 454 }; 476 - 477 - export { CollectionView };
+3 -3
src/views/labels.tsx
··· 9 9 import RecordHoverCard from "../components/hover-card/record.jsx"; 10 10 import { TextInput } from "../components/text-input.jsx"; 11 11 import { canHover } from "../layout.jsx"; 12 - import { labelerCache, resolveHandle, resolvePDS } from "../utils/api.js"; 12 + import { getPDS, labelerCache, resolveHandle } from "../lib/api.js"; 13 + import { useFilterShortcut } from "../lib/keyboard.js"; 13 14 import { localDateFromTimestamp } from "../utils/date.js"; 14 - import { useFilterShortcut } from "../utils/keyboard.js"; 15 15 16 16 const LABELS_PER_PAGE = 50; 17 17 const DEFAULT_LABELER_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; ··· 147 147 setError(undefined); 148 148 149 149 if (!isAtprotoDid(did)) did = await resolveHandle(did as Handle); 150 - await resolvePDS(did); 150 + await getPDS(did); 151 151 if (!labelerCache[did]) throw new Error("Repository is not a labeler"); 152 152 rpc = new Client({ 153 153 handler: simpleFetchHandler({ service: labelerCache[did] }),
+5 -5
src/views/logs.tsx src/views/repo/logs.tsx
··· 6 6 } from "@atcute/did-plc"; 7 7 import { useLocation } from "@solidjs/router"; 8 8 import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; 9 - import Tooltip from "../components/tooltip.jsx"; 10 - import { localDateFromTimestamp } from "../utils/date.js"; 11 - import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; 12 - import PlcValidateWorker from "../workers/plc-validate.ts?worker"; 13 - import { plcDirectory } from "./settings.jsx"; 9 + import Tooltip from "../../components/tooltip.jsx"; 10 + import { createOperationHistory, DiffEntry, groupBy } from "../../lib/plc-logs.js"; 11 + import { localDateFromTimestamp } from "../../utils/date.js"; 12 + import PlcValidateWorker from "../../workers/plc-validate.ts?worker"; 13 + import { plcDirectory } from "../settings.jsx"; 14 14 15 15 type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method"; 16 16
+206 -154
src/views/pds.tsx
··· 2 2 import { Client, simpleFetchHandler } from "@atcute/client"; 3 3 import { InferXRPCBodyOutput } from "@atcute/lexicons"; 4 4 import * as TID from "@atcute/tid"; 5 - import { A, useLocation, useParams } from "@solidjs/router"; 5 + import { A, type RouteSectionProps, useLocation, useParams } from "@solidjs/router"; 6 6 import { createWindowVirtualizer } from "@tanstack/solid-virtual"; 7 7 import { createEffect, createResource, createSignal, For, on, onCleanup, Show } from "solid-js"; 8 8 import { Button } from "../components/button"; 9 9 import { setPDS } from "../components/navbar"; 10 + import { NestedLayout } from "../components/nested-layout.jsx"; 11 + import { Spinner } from "../components/spinner.jsx"; 10 12 import { canHover } from "../layout"; 11 - import { didDocCache, resolveDidDoc } from "../utils/api"; 13 + import { didDocCache, resolveDidDoc } from "../lib/api"; 14 + import { createLatch } from "../lib/create-latch.js"; 12 15 import { localDateFromTimestamp } from "../utils/date"; 13 16 14 17 const LIMIT = 1000; 18 + 19 + let pdsCache: 20 + | { 21 + pds: string; 22 + repos: ComAtprotoSyncListRepos.Repo[]; 23 + cursor: string | undefined; 24 + version?: string; 25 + serverInfos?: InferXRPCBodyOutput<ComAtprotoServerDescribeServer.mainSchema["output"]>; 26 + } 27 + | undefined; 15 28 16 29 const RepoCard = (props: { 17 30 repo: ComAtprotoSyncListRepos.Repo; ··· 178 191 </div> 179 192 ); 180 193 181 - export const PdsView = () => { 194 + export const PdsLayout = (props: RouteSectionProps) => { 195 + const params = useParams(); 196 + const hasChild = () => !!params.repo; 197 + return ( 198 + <NestedLayout key={params.pds} hasChild={hasChild()} view={() => <PdsView />}> 199 + {props.children} 200 + </NestedLayout> 201 + ); 202 + }; 203 + 204 + const PdsView = () => { 182 205 const params = useParams(); 206 + const hidden = () => !!params.repo; 183 207 const location = useLocation(); 184 - const [version, setVersion] = createSignal<string>(); 185 - const [serverInfos, setServerInfos] = 186 - createSignal<InferXRPCBodyOutput<ComAtprotoServerDescribeServer.mainSchema["output"]>>(); 187 - const [cursor, setCursor] = createSignal<string>(); 188 208 setPDS(params.pds); 189 209 const pds = 190 210 params.pds!.startsWith("localhost") ? `http://${params.pds}` : `https://${params.pds}`; 191 211 const rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 212 + const cached = pdsCache?.pds === pds ? pdsCache : undefined; 213 + const [version, setVersion] = createSignal<string | undefined>(cached?.version); 214 + const [serverInfos, setServerInfos] = createSignal< 215 + InferXRPCBodyOutput<ComAtprotoServerDescribeServer.mainSchema["output"]> | undefined 216 + >(cached?.serverInfos); 217 + const [cursor, setCursor] = createSignal<string | undefined>(cached?.cursor); 218 + const [isLoadingMore, setIsLoadingMore] = createSignal(false); 192 219 193 220 const getVersion = async () => { 194 221 try { 195 222 // @ts-expect-error: undocumented endpoint 196 223 const res = await rpc.get("_health", {}); 197 - setVersion((res.data as any).version); 224 + const v = (res.data as any).version as string; 225 + setVersion(v); 226 + if (pdsCache) pdsCache.version = v; 198 227 } catch (err) { 199 228 console.error("Failed to fetch version:", err); 200 229 } ··· 203 232 const describeServer = async () => { 204 233 const res = await rpc.get("com.atproto.server.describeServer"); 205 234 if (!res.ok) console.error(res.data.error); 206 - else setServerInfos(res.data); 235 + else { 236 + setServerInfos(res.data); 237 + if (pdsCache) pdsCache.serverInfos = res.data; 238 + } 207 239 }; 208 240 209 - getVersion(); 210 - describeServer(); 241 + createEffect(() => { 242 + if (hidden() || version() || serverInfos()) return; 243 + getVersion(); 244 + describeServer(); 245 + }); 246 + 247 + const [repos, setRepos] = createSignal<ComAtprotoSyncListRepos.Repo[] | undefined>(cached?.repos); 211 248 212 249 const fetchRepos = async () => { 250 + const loadingMore = isLoadingMore(); 251 + setIsLoadingMore(false); 252 + if (!loadingMore && repos()) return; 213 253 const res = await rpc.get("com.atproto.sync.listRepos", { 214 254 params: { limit: LIMIT, cursor: cursor() }, 215 255 }); 216 256 if (!res.ok) throw new Error(res.data.error); 217 - setCursor(res.data.repos.length < LIMIT ? undefined : res.data.cursor); 218 - setRepos(repos()?.concat(res.data.repos) ?? res.data.repos); 257 + const newCursor = res.data.repos.length < LIMIT ? undefined : res.data.cursor; 258 + const newRepos = repos()?.concat(res.data.repos) ?? res.data.repos; 259 + setCursor(newCursor); 260 + setRepos(newRepos); 261 + pdsCache = { ...pdsCache, pds, repos: newRepos, cursor: newCursor }; 219 262 return res.data; 220 263 }; 221 264 222 - const [response, { refetch }] = createResource(fetchRepos); 223 - const [repos, setRepos] = createSignal<ComAtprotoSyncListRepos.Repo[]>(); 265 + const shouldFetch = createLatch(() => !hidden()); 266 + 267 + const [response, { refetch }] = createResource(shouldFetch, fetchRepos); 224 268 225 269 const [expandedIndex, setExpandedIndex] = createSignal<number | null>(null); 226 270 ··· 272 316 </A> 273 317 ); 274 318 275 - document.title = `${params.pds} - PDSls`; 319 + if (!hidden()) document.title = `${params.pds} - PDSls`; 276 320 277 321 return ( 278 - <Show when={repos() || response()}> 279 - <div class="flex w-full flex-col px-2"> 280 - <div class="mb-3 flex gap-4 text-sm sm:text-base"> 281 - <Tab tab="repos" label="Repositories" /> 282 - <Tab tab="info" label="Info" /> 283 - <Tab tab="firehose" label="Firehose" /> 284 - </div> 285 - <Show when={!location.hash || location.hash === "#repos"}> 286 - <div 287 - class="-mx-2 mb-9" 288 - ref={containerRef} 289 - style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }} 290 - > 291 - <For each={virtualizer.getVirtualItems()}> 292 - {(virtualItem) => { 293 - const isExpanded = () => expandedIndex() === virtualItem.index; 294 - return ( 295 - <div 296 - data-index={virtualItem.index} 297 - ref={virtualizer.measureElement} 298 - style={{ 299 - position: "absolute", 300 - top: `${virtualItem.start - virtualizer.options.scrollMargin}px`, 301 - left: 0, 302 - width: "100%", 303 - overflow: "visible", 304 - }} 305 - > 306 - <RepoCard 307 - repo={repos()![virtualItem.index]} 308 - expanded={isExpanded()} 309 - onToggle={() => setExpandedIndex(isExpanded() ? null : virtualItem.index)} 310 - /> 311 - </div> 312 - ); 313 - }} 314 - </For> 322 + <> 323 + <Show when={!hidden() && !repos() && (response.state === "unresolved" || response.loading)}> 324 + <Spinner /> 325 + </Show> 326 + <Show when={!hidden() && (repos() || response.state === "ready")}> 327 + <div class="flex w-full flex-col px-2"> 328 + <div class="mb-3 flex gap-4 text-sm sm:text-base"> 329 + <Tab tab="repos" label="Repositories" /> 330 + <Tab tab="info" label="Info" /> 331 + <Tab tab="firehose" label="Firehose" /> 315 332 </div> 316 - </Show> 317 - <div class="flex flex-col gap-3"> 318 - <Show when={location.hash === "#info"}> 319 - <Show when={version()}> 320 - {(version) => ( 321 - <InfoField label="Version"> 322 - <span class="text-sm text-neutral-700 dark:text-neutral-300">{version()}</span> 323 - </InfoField> 324 - )} 325 - </Show> 326 - <Show when={serverInfos()}> 327 - {(server) => ( 328 - <> 329 - <InfoField label="DID"> 330 - <span class="text-sm text-neutral-700 dark:text-neutral-300"> 331 - {server().did} 332 - </span> 333 - </InfoField> 334 - <div class="flex items-center gap-1"> 335 - <span class="font-semibold">Invite Code Required</span> 336 - <span 337 - classList={{ 338 - "iconify lucide--check text-green-500 dark:text-green-400": 339 - server().inviteCodeRequired === true, 340 - "iconify lucide--x text-red-500 dark:text-red-400": 341 - !server().inviteCodeRequired, 333 + <Show when={!location.hash || location.hash === "#repos"}> 334 + <div 335 + class="-mx-2 mb-9" 336 + ref={containerRef} 337 + style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }} 338 + > 339 + <For each={virtualizer.getVirtualItems()}> 340 + {(virtualItem) => { 341 + const isExpanded = () => expandedIndex() === virtualItem.index; 342 + return ( 343 + <div 344 + data-index={virtualItem.index} 345 + ref={virtualizer.measureElement} 346 + style={{ 347 + position: "absolute", 348 + top: `${virtualItem.start - virtualizer.options.scrollMargin}px`, 349 + left: 0, 350 + width: "100%", 351 + overflow: "visible", 342 352 }} 343 - ></span> 344 - </div> 345 - <Show when={server().phoneVerificationRequired}> 346 - <div class="flex items-center gap-1"> 347 - <span class="font-semibold">Captcha Verification Required</span> 348 - <span class="iconify lucide--check text-green-500 dark:text-green-400"></span> 353 + > 354 + <RepoCard 355 + repo={repos()![virtualItem.index]} 356 + expanded={isExpanded()} 357 + onToggle={() => setExpandedIndex(isExpanded() ? null : virtualItem.index)} 358 + /> 349 359 </div> 350 - </Show> 351 - <Show when={server().availableUserDomains.length}> 352 - <InfoField label="Available User Domains"> 353 - <For each={server().availableUserDomains}> 354 - {(domain) => ( 355 - <span class="text-sm wrap-anywhere text-neutral-700 dark:text-neutral-300"> 356 - {domain} 357 - </span> 358 - )} 359 - </For> 360 + ); 361 + }} 362 + </For> 363 + </div> 364 + </Show> 365 + <div class="flex flex-col gap-3"> 366 + <Show when={location.hash === "#info"}> 367 + <Show when={version()}> 368 + {(version) => ( 369 + <InfoField label="Version"> 370 + <span class="text-sm text-neutral-700 dark:text-neutral-300">{version()}</span> 371 + </InfoField> 372 + )} 373 + </Show> 374 + <Show when={serverInfos()}> 375 + {(server) => ( 376 + <> 377 + <InfoField label="DID"> 378 + <span class="text-sm text-neutral-700 dark:text-neutral-300"> 379 + {server().did} 380 + </span> 360 381 </InfoField> 361 - </Show> 362 - <For 363 - each={[ 364 - { label: "Privacy Policy", url: server().links?.privacyPolicy }, 365 - { label: "Terms of Service", url: server().links?.termsOfService }, 366 - { 367 - label: "Contact", 368 - url: 369 - server().contact?.email ? `mailto:${server().contact?.email}` : undefined, 370 - display: server().contact?.email, 371 - }, 372 - ].filter((l) => l.url)} 373 - > 374 - {(link) => ( 375 - <InfoField label={link.label}> 376 - <a 377 - href={link.url} 378 - class="text-sm text-neutral-700 hover:underline dark:text-neutral-300" 379 - target="_blank" 380 - rel="noopener" 381 - > 382 - {link.display ?? link.url} 383 - </a> 382 + <div class="flex items-center gap-1"> 383 + <span class="font-semibold">Invite Code Required</span> 384 + <span 385 + classList={{ 386 + "iconify lucide--check text-green-500 dark:text-green-400": 387 + server().inviteCodeRequired === true, 388 + "iconify lucide--x text-red-500 dark:text-red-400": 389 + !server().inviteCodeRequired, 390 + }} 391 + ></span> 392 + </div> 393 + <Show when={server().phoneVerificationRequired}> 394 + <div class="flex items-center gap-1"> 395 + <span class="font-semibold">Captcha Verification Required</span> 396 + <span class="iconify lucide--check text-green-500 dark:text-green-400"></span> 397 + </div> 398 + </Show> 399 + <Show when={server().availableUserDomains.length}> 400 + <InfoField label="Available User Domains"> 401 + <For each={server().availableUserDomains}> 402 + {(domain) => ( 403 + <span class="text-sm wrap-anywhere text-neutral-700 dark:text-neutral-300"> 404 + {domain} 405 + </span> 406 + )} 407 + </For> 384 408 </InfoField> 385 - )} 386 - </For> 387 - </> 388 - )} 389 - </Show> 390 - </Show> 391 - </div> 392 - </div> 393 - <Show when={!location.hash || location.hash === "#repos"}> 394 - <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center border-t border-neutral-200 bg-neutral-100 pt-3 pb-6 dark:border-neutral-700"> 395 - <div class="flex items-center gap-3"> 396 - <p> 397 - {repos()?.length} loaded 398 - <Show when={repos()?.some((r) => !r.active)}> 399 - {" · "} 400 - <span class="text-neutral-500 dark:text-neutral-400"> 401 - {repos()?.filter((r) => !r.active).length} inactive 402 - </span> 409 + </Show> 410 + <For 411 + each={[ 412 + { label: "Privacy Policy", url: server().links?.privacyPolicy }, 413 + { label: "Terms of Service", url: server().links?.termsOfService }, 414 + { 415 + label: "Contact", 416 + url: 417 + server().contact?.email ? 418 + `mailto:${server().contact?.email}` 419 + : undefined, 420 + display: server().contact?.email, 421 + }, 422 + ].filter((l) => l.url)} 423 + > 424 + {(link) => ( 425 + <InfoField label={link.label}> 426 + <a 427 + href={link.url} 428 + class="text-sm text-neutral-700 hover:underline dark:text-neutral-300" 429 + target="_blank" 430 + rel="noopener" 431 + > 432 + {link.display ?? link.url} 433 + </a> 434 + </InfoField> 435 + )} 436 + </For> 437 + </> 438 + )} 403 439 </Show> 404 - </p> 405 - <Show when={cursor()}> 406 - <Button 407 - onClick={() => { 408 - setExpandedIndex(null); 409 - refetch(); 410 - }} 411 - disabled={response.loading} 412 - classList={{ "w-20 h-7.5 justify-center": true }} 413 - > 414 - <Show 415 - when={!response.loading} 416 - fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 417 - > 418 - Load more 419 - </Show> 420 - </Button> 421 440 </Show> 422 441 </div> 423 442 </div> 443 + <Show when={!location.hash || location.hash === "#repos"}> 444 + <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center border-t border-neutral-200 bg-neutral-100 pt-3 pb-6 dark:border-neutral-700"> 445 + <div class="flex items-center gap-3"> 446 + <p> 447 + {repos()?.length} loaded 448 + <Show when={repos()?.some((r) => !r.active)}> 449 + {" · "} 450 + <span class="text-neutral-500 dark:text-neutral-400"> 451 + {repos()?.filter((r) => !r.active).length} inactive 452 + </span> 453 + </Show> 454 + </p> 455 + <Show when={cursor()}> 456 + <Button 457 + onClick={() => { 458 + setIsLoadingMore(true); 459 + setExpandedIndex(null); 460 + refetch(); 461 + }} 462 + disabled={response.loading} 463 + classList={{ "w-20 h-7.5 justify-center": true }} 464 + > 465 + <Show 466 + when={!response.loading} 467 + fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 468 + > 469 + Load more 470 + </Show> 471 + </Button> 472 + </Show> 473 + </div> 474 + </div> 475 + </Show> 424 476 </Show> 425 - </Show> 477 + </> 426 478 ); 427 479 };
+42 -36
src/views/record.tsx
··· 17 17 import { JSONValue } from "../components/json.jsx"; 18 18 import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 19 19 import { Modal } from "../components/modal.jsx"; 20 - import { pds } from "../components/navbar.jsx"; 21 20 import { addNotification, removeNotification } from "../components/notification.jsx"; 22 21 import { PermissionButton } from "../components/permission-button.jsx"; 23 22 import { canHover } from "../layout.jsx"; 23 + import { useRepo } from "../lib/repo-context.jsx"; 24 24 import { 25 25 didDocumentResolver, 26 26 resolveLexiconAuthority, 27 27 resolveLexiconSchema, 28 - resolvePDS, 29 - } from "../utils/api.js"; 28 + } from "../lib/api.js"; 30 29 import { addToClipboard } from "../utils/copy.js"; 31 - import { clearCollectionCache } from "../utils/route-cache.js"; 32 - import { AtUri, uriTemplates } from "../utils/templates.js"; 33 - import { lexicons } from "../utils/types/lexicons.js"; 30 + import { AtUri, uriTemplates } from "../lib/templates.js"; 31 + import { lexicons } from "../lib/types/lexicons.js"; 34 32 35 33 const faviconWrapper = (children: any) => ( 36 34 <div class="flex size-4 items-center justify-center">{children}</div> ··· 214 212 }; 215 213 216 214 export const RecordView = () => { 215 + const repo = useRepo(); 217 216 const location = useLocation(); 218 217 const navigate = useNavigate(); 219 218 const params = useParams(); ··· 230 229 const [schema, setSchema] = createSignal<ResolvedSchema>(); 231 230 const [lexiconNotFound, setLexiconNotFound] = createSignal<boolean>(); 232 231 const [remoteValidation, setRemoteValidation] = createSignal<boolean>(); 233 - const did = params.repo; 234 - let rpc: Client; 232 + const did = repo.did(); 235 233 236 234 const fetchRecord = async () => { 235 + const rpc = repo.rpc()!; 236 + const collection = params.collection!; 237 + const rkey = params.rkey!; 237 238 setValidRecord(undefined); 238 239 setValidSchema(undefined); 239 - const pds = await resolvePDS(did!); 240 - rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 241 240 const res = await rpc.get("com.atproto.repo.getRecord", { 242 241 params: { 243 242 repo: did as ActorIdentifier, 244 - collection: params.collection as `${string}.${string}.${string}`, 245 - rkey: params.rkey!, 243 + collection: collection as `${string}.${string}.${string}`, 244 + rkey, 246 245 }, 247 246 }); 248 247 if (!res.ok) { ··· 252 251 } 253 252 setPlaceholder(res.data.value); 254 253 setExternalLink(checkUri(res.data.uri, res.data.value)); 255 - resolveLexicon(params.collection as Nsid); 256 - verifyRecordIntegrity(); 257 - validateLocalSchema(res.data.value); 254 + resolveLexicon(collection as Nsid, collection); 255 + verifyRecordIntegrity(rpc, collection, rkey); 256 + validateLocalSchema(collection, res.data.value); 258 257 259 258 return res.data; 260 259 }; 261 260 262 - const [record, { refetch }] = createResource(fetchRecord); 261 + const [record, { refetch }] = createResource( 262 + () => (repo.rpc() ? params.rkey : undefined), 263 + fetchRecord, 264 + ); 263 265 264 - const validateLocalSchema = async (record: Record<string, unknown>) => { 266 + const validateLocalSchema = async (collection: string, record: Record<string, unknown>) => { 265 267 try { 266 - if (params.collection === "com.atproto.lexicon.schema") { 268 + if (collection === "com.atproto.lexicon.schema") { 267 269 setLexiconNotFound(false); 268 270 lexiconDoc.parse(record, { mode: "passthrough" }); 269 271 setValidSchema(true); 270 - } else if (params.collection && params.collection in lexicons) { 271 - if (is(lexicons[params.collection], record)) setValidSchema(true); 272 + } else if (collection in lexicons) { 273 + if (is(lexicons[collection], record)) setValidSchema(true); 272 274 else setValidSchema(false); 273 275 } 274 276 } catch (err: any) { ··· 279 281 }; 280 282 281 283 const validateRemoteSchema = async (record: Record<string, unknown>) => { 284 + const collection = params.collection!; 285 + const rkey = params.rkey!; 282 286 try { 283 287 setRemoteValidation(true); 284 - const { resolved, failed } = await resolveAllLexicons(params.collection as Nsid); 288 + const { resolved, failed } = await resolveAllLexicons(collection as Nsid); 285 289 286 290 if (failed.size > 0) { 287 291 console.error(`Failed to resolve ${failed.size} documents:`, Array.from(failed)); ··· 292 296 293 297 const lexiconDocs = Object.fromEntries(resolved); 294 298 295 - const validator = new RecordValidator(lexiconDocs, params.collection as Nsid); 299 + const validator = new RecordValidator(lexiconDocs, collection as Nsid); 296 300 validator.parse({ 297 - key: params.rkey ?? null, 301 + key: rkey ?? null, 298 302 object: record, 299 303 }); 300 304 ··· 307 311 setRemoteValidation(false); 308 312 }; 309 313 310 - const verifyRecordIntegrity = async () => { 314 + const verifyRecordIntegrity = async (rpc: Client, collection: string, rkey: string) => { 311 315 try { 312 316 const { ok, data } = await rpc.get("com.atproto.sync.getRecord", { 313 317 params: { 314 318 did: did as Did, 315 - collection: params.collection as Nsid, 316 - rkey: params.rkey!, 319 + collection: collection as Nsid, 320 + rkey, 317 321 }, 318 322 as: "bytes", 319 323 }); ··· 321 325 322 326 await verifyRecord({ 323 327 did: did as AtprotoDid, 324 - collection: params.collection!, 325 - rkey: params.rkey!, 328 + collection, 329 + rkey, 326 330 carBytes: data as Uint8Array<ArrayBufferLike>, 327 331 }); 328 332 ··· 334 338 } 335 339 }; 336 340 337 - const resolveLexicon = async (nsid: Nsid) => { 341 + const resolveLexicon = async (nsid: Nsid, collection: string) => { 338 342 try { 339 343 const authority = await resolveLexiconAuthority(nsid); 340 344 setLexiconAuthority(authority); 341 - if (params.collection !== "com.atproto.lexicon.schema") { 345 + if (collection !== "com.atproto.lexicon.schema") { 342 346 const schema = await resolveLexiconSchema(authority, nsid); 343 347 setSchema(schema); 344 348 setLexiconNotFound(false); ··· 349 353 }; 350 354 351 355 const deleteRecord = async () => { 352 - rpc = new Client({ handler: agent()! }); 353 - await rpc.post("com.atproto.repo.deleteRecord", { 356 + const collection = params.collection!; 357 + const rkey = params.rkey!; 358 + const authRpc = new Client({ handler: agent()! }); 359 + await authRpc.post("com.atproto.repo.deleteRecord", { 354 360 input: { 355 361 repo: params.repo as ActorIdentifier, 356 - collection: params.collection as `${string}.${string}.${string}`, 357 - rkey: params.rkey!, 362 + collection: collection as `${string}.${string}.${string}`, 363 + rkey, 358 364 }, 359 365 }); 360 366 const id = addNotification({ ··· 362 368 type: "success", 363 369 }); 364 370 setTimeout(() => removeNotification(id), 3000); 365 - clearCollectionCache(`${params.pds}/${params.repo}/${params.collection}`); 366 371 navigate(`/at://${params.repo}/${params.collection}`); 367 372 }; 368 373 ··· 526 531 icon="lucide--copy" 527 532 /> 528 533 <NavMenu 529 - href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`} 534 + href={`${repo.pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`} 530 535 icon="lucide--external-link" 531 536 label="Record on PDS" 532 537 newTab ··· 540 545 <JSONValue 541 546 data={record()?.value as any} 542 547 repo={record()!.uri.split("/")[2]} 548 + pds={repo.pds()} 543 549 keyLinks 544 550 /> 545 551 </div>
-852
src/views/repo.tsx
··· 1 - import { Client, simpleFetchHandler } from "@atcute/client"; 2 - import { DidDocument } from "@atcute/identity"; 3 - import { ActorIdentifier, Did, Handle, Nsid } from "@atcute/lexicons"; 4 - import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 5 - import { 6 - createEffect, 7 - createResource, 8 - createSignal, 9 - ErrorBoundary, 10 - For, 11 - onMount, 12 - Show, 13 - Suspense, 14 - } from "solid-js"; 15 - import { createStore } from "solid-js/store"; 16 - import { Backlinks } from "../components/backlinks.jsx"; 17 - import { 18 - ActionMenu, 19 - DropdownMenu, 20 - MenuProvider, 21 - MenuSeparator, 22 - NavMenu, 23 - } from "../components/dropdown.jsx"; 24 - import { Favicon } from "../components/favicon.jsx"; 25 - import { 26 - addNotification, 27 - removeNotification, 28 - updateNotification, 29 - } from "../components/notification.jsx"; 30 - import { canHover } from "../layout.jsx"; 31 - import { 32 - didDocCache, 33 - type HandleResolveResult, 34 - labelerCache, 35 - resolveHandle, 36 - resolveHandleDetailed, 37 - resolveLexiconAuthority, 38 - resolvePDS, 39 - validateHandle, 40 - } from "../utils/api.js"; 41 - import { addToClipboard } from "../utils/copy.js"; 42 - import { detectDidKeyType, detectKeyType } from "../utils/key.js"; 43 - import { useFilterShortcut } from "../utils/keyboard.js"; 44 - import { BlobView } from "./blob.jsx"; 45 - import { PlcLogView } from "./logs.jsx"; 46 - import { plcDirectory } from "./settings.jsx"; 47 - 48 - export const RepoView = () => { 49 - const params = useParams(); 50 - const location = useLocation(); 51 - const navigate = useNavigate(); 52 - const [error, setError] = createSignal<string>(); 53 - const [downloading, setDownloading] = createSignal(false); 54 - const [didDoc, setDidDoc] = createSignal<DidDocument>(); 55 - const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 56 - const [filter, setFilter] = createSignal<string>(); 57 - const [validHandles, setValidHandles] = createStore<Record<string, boolean>>({}); 58 - const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]); 59 - const [expandedAlias, setExpandedAlias] = createSignal<string | null>(null); 60 - const [handleDetailedResult, setHandleDetailedResult] = createSignal<{ 61 - dns: HandleResolveResult; 62 - http: HandleResolveResult; 63 - } | null>(null); 64 - let rpc: Client; 65 - let pds: string; 66 - let filterInputRef: HTMLInputElement | undefined; 67 - const did = params.repo!; 68 - 69 - // Handle scrolling to a collection group when hash is like #collections:app.bsky 70 - createEffect(() => { 71 - const hash = location.hash; 72 - if (hash.startsWith("#collections:")) { 73 - const authority = hash.slice(13); 74 - requestAnimationFrame(() => { 75 - const element = document.getElementById(`collection-${authority}`); 76 - if (element) element.scrollIntoView({ behavior: "instant", block: "start" }); 77 - }); 78 - } 79 - }); 80 - 81 - onMount(() => { 82 - useFilterShortcut(() => filterInputRef); 83 - }); 84 - 85 - const RepoTab = (props: { 86 - tab: "collections" | "backlinks" | "identity" | "blobs" | "logs"; 87 - label: string; 88 - }) => { 89 - const isActive = () => { 90 - if (!location.hash) { 91 - if (!error() && props.tab === "collections") return true; 92 - if (!!error() && props.tab === "identity") return true; 93 - return false; 94 - } 95 - return location.hash.startsWith(`#${props.tab}`); 96 - }; 97 - 98 - return ( 99 - <A 100 - classList={{ 101 - "border-b-2 font-medium transition-colors": true, 102 - "border-transparent not-hover:text-neutral-600 not-hover:dark:text-neutral-300/80": 103 - !isActive(), 104 - }} 105 - href={`/at://${params.repo}#${props.tab}`} 106 - > 107 - {props.label} 108 - </A> 109 - ); 110 - }; 111 - 112 - const getRotationKeys = async () => { 113 - const res = await fetch(`${plcDirectory()}/${did}/log/last`); 114 - const json = await res.json(); 115 - setRotationKeys(json.rotationKeys ?? []); 116 - }; 117 - 118 - const fetchRepo = async () => { 119 - try { 120 - pds = await resolvePDS(did); 121 - setDidDoc(didDocCache[did] as DidDocument); 122 - } catch { 123 - // Fallback for feedgens 124 - if (did.startsWith("did:web")) { 125 - try { 126 - const res = await fetch(`https://${did.replace("did:web:", "")}/.well-known/did.json`); 127 - const didDoc = await res.json(); 128 - setDidDoc(didDoc); 129 - } catch {} 130 - } else if (!did.startsWith("did:")) { 131 - try { 132 - const did = await resolveHandle(params.repo as Handle); 133 - navigate(location.pathname.replace(params.repo!, did), { replace: true }); 134 - return; 135 - } catch { 136 - try { 137 - const nsid = params.repo as Nsid; 138 - const res = await resolveLexiconAuthority(nsid); 139 - navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`, { replace: true }); 140 - return; 141 - } catch { 142 - navigate(`/${did}`, { replace: true }); 143 - return; 144 - } 145 - } 146 - } 147 - } 148 - 149 - if (did.startsWith("did:plc")) getRotationKeys(); 150 - validateHandles(); 151 - 152 - if (!pds) { 153 - setError("Missing PDS"); 154 - return {}; 155 - } 156 - 157 - rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 158 - try { 159 - const res = await rpc.get("com.atproto.repo.describeRepo", { 160 - params: { repo: did as ActorIdentifier }, 161 - }); 162 - if (res.ok) { 163 - const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 164 - res.data.collections.forEach((c) => { 165 - const nsid = c.split("."); 166 - if (nsid.length > 2) { 167 - const authority = `${nsid[0]}.${nsid[1]}`; 168 - collections[authority] = { 169 - nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 170 - hidden: false, 171 - }; 172 - } 173 - }); 174 - setNsids(collections); 175 - } else { 176 - console.error(res.data.error); 177 - switch (res.data.error) { 178 - case "RepoDeactivated": 179 - setError("Deactivated"); 180 - break; 181 - case "RepoTakendown": 182 - setError("Taken down"); 183 - break; 184 - default: 185 - setError("Unreachable"); 186 - } 187 - } 188 - 189 - return res.data; 190 - } catch { 191 - return {}; 192 - } 193 - }; 194 - 195 - const [repo] = createResource(fetchRepo); 196 - 197 - const toggleCollapsed = (authority: string) => { 198 - setNsids((prev) => ({ 199 - ...prev!, 200 - [authority]: { ...prev![authority], hidden: !prev![authority].hidden }, 201 - })); 202 - }; 203 - 204 - const collapseAll = () => { 205 - setNsids((prev) => 206 - Object.fromEntries(Object.entries(prev!).map(([k, v]) => [k, { ...v, hidden: true }])), 207 - ); 208 - }; 209 - 210 - const expandAll = () => { 211 - setNsids((prev) => 212 - Object.fromEntries(Object.entries(prev!).map(([k, v]) => [k, { ...v, hidden: false }])), 213 - ); 214 - }; 215 - 216 - const validateHandles = async () => { 217 - for (const alias of didDoc()?.alsoKnownAs ?? []) { 218 - if (alias.startsWith("at://")) 219 - setValidHandles( 220 - alias, 221 - await validateHandle(alias.replace("at://", "") as Handle, did as Did), 222 - ); 223 - } 224 - }; 225 - 226 - const downloadRepo = async () => { 227 - let notificationId: string | null = null; 228 - const abortController = new AbortController(); 229 - 230 - try { 231 - setDownloading(true); 232 - notificationId = addNotification({ 233 - message: "Downloading repository...", 234 - progress: 0, 235 - total: 0, 236 - type: "info", 237 - onCancel: () => { 238 - abortController.abort(); 239 - if (notificationId) { 240 - removeNotification(notificationId); 241 - } 242 - }, 243 - }); 244 - 245 - const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`, { 246 - signal: abortController.signal, 247 - }); 248 - if (!response.ok) { 249 - throw new Error(`HTTP error status: ${response.status}`); 250 - } 251 - 252 - const contentLength = response.headers.get("content-length"); 253 - const total = contentLength ? parseInt(contentLength, 10) : 0; 254 - let loaded = 0; 255 - 256 - const reader = response.body?.getReader(); 257 - const chunks: BlobPart[] = []; 258 - 259 - if (reader) { 260 - while (true) { 261 - const { done, value } = await reader.read(); 262 - if (done) break; 263 - 264 - chunks.push(value); 265 - loaded += value.length; 266 - 267 - if (total > 0) { 268 - const progress = Math.round((loaded / total) * 100); 269 - updateNotification(notificationId, { 270 - progress, 271 - total, 272 - }); 273 - } else { 274 - const progressMB = Math.round((loaded / (1024 * 1024)) * 10) / 10; 275 - updateNotification(notificationId, { 276 - progress: progressMB, 277 - total: 0, 278 - }); 279 - } 280 - } 281 - } 282 - 283 - const blob = new Blob(chunks); 284 - const url = window.URL.createObjectURL(blob); 285 - const a = document.createElement("a"); 286 - a.href = url; 287 - a.download = `${did}-${new Date().toISOString()}.car`; 288 - document.body.appendChild(a); 289 - a.click(); 290 - 291 - window.URL.revokeObjectURL(url); 292 - document.body.removeChild(a); 293 - 294 - updateNotification(notificationId, { 295 - message: "Repository downloaded successfully", 296 - type: "success", 297 - progress: undefined, 298 - onCancel: undefined, 299 - }); 300 - setTimeout(() => { 301 - if (notificationId) removeNotification(notificationId); 302 - }, 3000); 303 - } catch (error) { 304 - if (!(error instanceof Error && error.name === "AbortError")) { 305 - console.error("Download failed:", error); 306 - if (notificationId) { 307 - updateNotification(notificationId, { 308 - message: "Download failed", 309 - type: "error", 310 - progress: undefined, 311 - onCancel: undefined, 312 - }); 313 - setTimeout(() => { 314 - if (notificationId) removeNotification(notificationId); 315 - }, 5000); 316 - } 317 - } 318 - } 319 - setDownloading(false); 320 - }; 321 - 322 - document.title = `${params.repo} - PDSls`; 323 - 324 - createEffect(() => { 325 - const handle = didDoc() 326 - ?.alsoKnownAs?.find((alias) => alias.startsWith("at://")) 327 - ?.replace("at://", ""); 328 - if (handle) document.title = `${handle} - PDSls`; 329 - }); 330 - 331 - return ( 332 - <> 333 - <Show when={repo()}> 334 - <div class="flex w-full flex-col gap-3 wrap-break-word"> 335 - <div class="flex justify-between px-2 text-sm sm:text-base"> 336 - <div class="flex items-center gap-3 sm:gap-4"> 337 - <Show when={!error()}> 338 - <RepoTab tab="collections" label="Collections" /> 339 - </Show> 340 - <RepoTab tab="identity" label="Identity" /> 341 - <Show when={did.startsWith("did:plc")}> 342 - <RepoTab tab="logs" label="Logs" /> 343 - </Show> 344 - <Show when={!error()}> 345 - <RepoTab tab="blobs" label="Blobs" /> 346 - </Show> 347 - <RepoTab tab="backlinks" label="Backlinks" /> 348 - </div> 349 - <div class="flex gap-1"> 350 - <Show when={error() && error() !== "Missing PDS"}> 351 - <div class="flex items-center gap-1 rounded-md border border-red-500 px-1.5 py-0.5 text-xs font-medium text-red-500 sm:text-sm dark:border-red-400 dark:text-red-400"> 352 - <span 353 - class={`iconify ${ 354 - error() === "Deactivated" ? "lucide--user-round-x" 355 - : error() === "Taken down" ? "lucide--shield-ban" 356 - : "lucide--unplug" 357 - }`} 358 - ></span> 359 - <span>{error()}</span> 360 - </div> 361 - </Show> 362 - <MenuProvider> 363 - <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5"> 364 - <NavMenu 365 - href={`/jetstream?dids=${params.repo}`} 366 - label="Jetstream" 367 - icon="lucide--radio-tower" 368 - /> 369 - <Show when={params.repo && params.repo in labelerCache}> 370 - <NavMenu 371 - href={`/labels?did=${params.repo}&uriPatterns=*`} 372 - label="Labels" 373 - icon="lucide--tag" 374 - /> 375 - </Show> 376 - <Show when={error()?.length === 0 || error() === undefined}> 377 - <ActionMenu 378 - label="Download repo" 379 - icon={ 380 - downloading() ? "lucide--loader-circle animate-spin" : "lucide--download" 381 - } 382 - onClick={() => downloadRepo()} 383 - /> 384 - </Show> 385 - <MenuSeparator /> 386 - <NavMenu 387 - href={ 388 - did.startsWith("did:plc") ? 389 - `${plcDirectory()}/${did}` 390 - : `https://${did.split("did:web:")[1]}/.well-known/did.json` 391 - } 392 - newTab 393 - label="DID document" 394 - icon="lucide--external-link" 395 - /> 396 - <Show when={did.startsWith("did:plc")}> 397 - <NavMenu 398 - href={`${plcDirectory()}/${did}/log/audit`} 399 - newTab 400 - label="Audit log" 401 - icon="lucide--external-link" 402 - /> 403 - </Show> 404 - </DropdownMenu> 405 - </MenuProvider> 406 - </div> 407 - </div> 408 - <div class="flex w-full flex-col gap-1 px-2"> 409 - <Show when={location.hash.startsWith("#logs")}> 410 - <ErrorBoundary 411 - fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 412 - > 413 - <Suspense 414 - fallback={ 415 - <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 416 - } 417 - > 418 - <PlcLogView did={did} /> 419 - </Suspense> 420 - </ErrorBoundary> 421 - </Show> 422 - <Show when={location.hash === "#backlinks"}> 423 - <ErrorBoundary 424 - fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 425 - > 426 - <Suspense 427 - fallback={ 428 - <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 429 - } 430 - > 431 - <Backlinks target={did} /> 432 - </Suspense> 433 - </ErrorBoundary> 434 - </Show> 435 - <Show when={location.hash === "#blobs"}> 436 - <ErrorBoundary 437 - fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 438 - > 439 - <Suspense 440 - fallback={ 441 - <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 442 - } 443 - > 444 - <BlobView pds={pds!} repo={did} /> 445 - </Suspense> 446 - </ErrorBoundary> 447 - </Show> 448 - <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 449 - <div class="flex flex-col pb-20 text-sm wrap-anywhere"> 450 - <Show 451 - when={Object.keys(nsids() ?? {}).length != 0} 452 - fallback={<span class="mt-3 text-center text-base">No collections found.</span>} 453 - > 454 - <For 455 - each={Object.keys(nsids() ?? {}).filter((authority) => 456 - filter() ? 457 - authority.includes(filter()!) || 458 - nsids()?.[authority].nsids.some((nsid) => 459 - `${authority}.${nsid}`.includes(filter()!), 460 - ) 461 - : true, 462 - )} 463 - > 464 - {(authority) => { 465 - const isHighlighted = () => location.hash === `#collections:${authority}`; 466 - const isCollapsed = () => nsids()?.[authority].hidden ?? false; 467 - 468 - return ( 469 - <div 470 - id={`collection-${authority}`} 471 - class="group relative flex scroll-mt-4 items-start gap-2 rounded-lg p-1 transition-colors" 472 - classList={{ 473 - "dark:hover:bg-dark-300 hover:bg-neutral-200": !isHighlighted(), 474 - "bg-blue-100 dark:bg-blue-500/25": isHighlighted(), 475 - }} 476 - > 477 - <Favicon 478 - domain={authority} 479 - reverse 480 - wrapper={(children) => ( 481 - <a 482 - href={`#collections:${authority}`} 483 - class="relative flex h-5 w-4 shrink-0 items-center justify-center hover:opacity-70" 484 - > 485 - <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"> 486 - <span class="iconify lucide--link absolute -left-2 w-7"></span> 487 - </span> 488 - {children} 489 - </a> 490 - )} 491 - /> 492 - <Show 493 - when={!isCollapsed()} 494 - fallback={ 495 - <button 496 - class="flex flex-1 items-center text-left" 497 - onClick={() => toggleCollapsed(authority)} 498 - > 499 - <span class="text-neutral-700 dark:text-neutral-300"> 500 - {authority} 501 - </span> 502 - <span class="text-neutral-500 dark:text-neutral-400">.*</span> 503 - <span class="ml-1.5 text-neutral-400 dark:text-neutral-500"> 504 - ({nsids()?.[authority].nsids.length}) 505 - </span> 506 - </button> 507 - } 508 - > 509 - <div class="flex min-w-0 flex-1 flex-col"> 510 - <For 511 - each={nsids()?.[authority].nsids.filter((nsid) => 512 - filter() ? `${authority}.${nsid}`.includes(filter()!) : true, 513 - )} 514 - > 515 - {(nsid, index) => ( 516 - <A 517 - href={`/at://${did}/${authority}.${nsid}`} 518 - class="truncate hover:underline active:underline" 519 - classList={{ "pr-16": canHover && index() === 0 }} 520 - > 521 - <span class="text-neutral-800/70 dark:text-neutral-200/70"> 522 - {authority}. 523 - </span> 524 - <span>{nsid}</span> 525 - </A> 526 - )} 527 - </For> 528 - </div> 529 - </Show> 530 - <Show when={canHover}> 531 - <button 532 - class="absolute top-1 right-1 rounded px-2 py-0.5 text-xs text-neutral-500 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-neutral-300 hover:text-neutral-700 active:bg-neutral-400 dark:text-neutral-400 dark:hover:bg-neutral-600 dark:hover:text-neutral-200 dark:active:bg-neutral-500" 533 - onClick={() => toggleCollapsed(authority)} 534 - > 535 - {isCollapsed() ? "expand" : "collapse"} 536 - </button> 537 - </Show> 538 - </div> 539 - ); 540 - }} 541 - </For> 542 - </Show> 543 - </div> 544 - </Show> 545 - <Show when={location.hash === "#identity" || (error() && !location.hash)}> 546 - <Show when={didDoc()}> 547 - {(didDocument) => ( 548 - <div class="flex flex-col gap-3 wrap-anywhere"> 549 - {/* ID Section */} 550 - <div> 551 - <div class="font-semibold">DID</div> 552 - <button 553 - class="group flex w-full items-center gap-1 text-left text-sm text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-200" 554 - onClick={() => addToClipboard(didDocument().id)} 555 - > 556 - <span class="truncate">{didDocument().id}</span> 557 - <span 558 - classList={{ 559 - "iconify lucide--copy shrink-0": true, 560 - "opacity-0 group-hover:opacity-100": canHover, 561 - }} 562 - ></span> 563 - </button> 564 - </div> 565 - 566 - {/* Aliases Section */} 567 - <Show when={didDocument().alsoKnownAs}> 568 - <div> 569 - <p class="font-semibold">Aliases</p> 570 - <For each={didDocument().alsoKnownAs}> 571 - {(alias) => ( 572 - <Show 573 - when={alias.startsWith("at://")} 574 - fallback={ 575 - <div class="text-sm text-neutral-700 dark:text-neutral-300"> 576 - {alias} 577 - </div> 578 - } 579 - > 580 - <div class="flex flex-col gap-2"> 581 - <button 582 - class="-ml-1 flex w-fit items-center gap-1 rounded px-1 py-0.5 text-left text-sm text-neutral-700 hover:bg-neutral-200 active:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-700" 583 - onClick={async () => { 584 - if (expandedAlias() === alias) { 585 - setExpandedAlias(null); 586 - } else { 587 - setHandleDetailedResult(null); 588 - setExpandedAlias(alias); 589 - const handle = alias.replace("at://", "") as Handle; 590 - const result = await resolveHandleDetailed(handle); 591 - if (expandedAlias() === alias) 592 - setHandleDetailedResult(result); 593 - } 594 - }} 595 - > 596 - <span>{alias}</span> 597 - <span 598 - classList={{ 599 - "iconify text-base shrink-0 lucide--check text-green-600 dark:text-green-400": 600 - validHandles[alias] === true, 601 - "iconify lucide--x text-red-500 dark:text-red-400": 602 - validHandles[alias] === false, 603 - "iconify lucide--loader-circle animate-spin": 604 - validHandles[alias] === undefined, 605 - }} 606 - ></span> 607 - </button> 608 - 609 - {/* Inline expansion */} 610 - <Show when={expandedAlias() === alias}> 611 - <div class="mb-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 dark:border-neutral-700 dark:bg-neutral-800/50"> 612 - <Show 613 - when={handleDetailedResult()} 614 - fallback={ 615 - <div class="flex items-center gap-2 py-2 text-sm"> 616 - <span class="iconify lucide--loader-circle animate-spin"></span> 617 - <span>Resolving handle...</span> 618 - </div> 619 - } 620 - > 621 - {(result) => { 622 - const expectedDid = didDocument().id; 623 - const dnsOk = () => 624 - result().dns.success && result().dns.did === expectedDid; 625 - const httpOk = () => 626 - result().http.success && 627 - result().http.did === expectedDid; 628 - const dnsMismatch = () => 629 - result().dns.success && result().dns.did !== expectedDid; 630 - const httpMismatch = () => 631 - result().http.success && 632 - result().http.did !== expectedDid; 633 - 634 - return ( 635 - <div class="grid grid-cols-[auto_1fr] items-center gap-x-1.5 text-sm"> 636 - {/* DNS Result */} 637 - <span 638 - classList={{ 639 - "iconify lucide--check text-green-600 dark:text-green-400": 640 - dnsOk(), 641 - "iconify lucide--x text-red-500 dark:text-red-400": 642 - !dnsOk(), 643 - }} 644 - ></span> 645 - <span class="font-medium">DNS (TXT record)</span> 646 - <span></span> 647 - <div class="mb-2 text-sm wrap-anywhere text-neutral-500 dark:text-neutral-400"> 648 - <Show 649 - when={result().dns.success} 650 - fallback={ 651 - <div class="text-red-500 dark:text-red-400"> 652 - {result().dns.error} 653 - </div> 654 - } 655 - > 656 - <div>{result().dns.did}</div> 657 - <Show when={dnsMismatch()}> 658 - <div class="text-red-500 dark:text-red-400"> 659 - Expected: {expectedDid} 660 - </div> 661 - </Show> 662 - </Show> 663 - </div> 664 - 665 - {/* HTTP Result */} 666 - <span 667 - classList={{ 668 - "iconify lucide--check text-green-600 dark:text-green-400": 669 - httpOk(), 670 - "iconify lucide--x text-red-500 dark:text-red-400": 671 - !httpOk(), 672 - }} 673 - ></span> 674 - <span class="font-medium">HTTP (.well-known)</span> 675 - <span></span> 676 - <div class="text-sm wrap-anywhere text-neutral-500 dark:text-neutral-400"> 677 - <Show 678 - when={result().http.success} 679 - fallback={ 680 - <div class="text-red-500 dark:text-red-400"> 681 - {result().http.error} 682 - </div> 683 - } 684 - > 685 - <div>{result().http.did}</div> 686 - <Show when={httpMismatch()}> 687 - <div class="text-red-500 dark:text-red-400"> 688 - Expected: {expectedDid} 689 - </div> 690 - </Show> 691 - </Show> 692 - </div> 693 - </div> 694 - ); 695 - }} 696 - </Show> 697 - </div> 698 - </Show> 699 - </div> 700 - </Show> 701 - )} 702 - </For> 703 - </div> 704 - </Show> 705 - 706 - {/* Services Section */} 707 - <Show when={didDocument().service}> 708 - <div> 709 - <p class="font-semibold">Services</p> 710 - <div class="flex flex-col gap-1"> 711 - <For each={didDocument().service}> 712 - {(service) => ( 713 - <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 714 - <span class="iconify lucide--hash"></span> 715 - <div class="flex items-center gap-2"> 716 - <span>{service.id.split("#")[1]}</span> 717 - <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400"> 718 - <span 719 - class="iconify text-xs" 720 - classList={{ 721 - "lucide--hard-drive": 722 - service.type === "AtprotoPersonalDataServer", 723 - "lucide--tag": service.type === "AtprotoLabeler", 724 - "lucide--rss": service.type === "BskyFeedGenerator", 725 - "lucide--wrench": ![ 726 - "AtprotoPersonalDataServer", 727 - "AtprotoLabeler", 728 - "BskyFeedGenerator", 729 - ].includes(service.type.toString()), 730 - }} 731 - ></span> 732 - <span>{service.type}</span> 733 - </div> 734 - </div> 735 - <span></span> 736 - <a 737 - class="w-fit underline hover:text-blue-500 dark:hover:text-blue-400" 738 - href={service.serviceEndpoint.toString()} 739 - target="_blank" 740 - rel="noopener" 741 - > 742 - {service.serviceEndpoint.toString()} 743 - </a> 744 - </div> 745 - )} 746 - </For> 747 - </div> 748 - </div> 749 - </Show> 750 - 751 - {/* Verification Methods Section */} 752 - <Show when={didDocument().verificationMethod}> 753 - <div> 754 - <p class="font-semibold">Verification Methods</p> 755 - <div class="flex flex-col gap-1"> 756 - <For each={didDocument().verificationMethod}> 757 - {(verif) => ( 758 - <Show when={verif.publicKeyMultibase}> 759 - {(key) => ( 760 - <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 761 - <span class="iconify lucide--hash"></span> 762 - <div class="flex items-center gap-2"> 763 - <span>{verif.id.split("#")[1]}</span> 764 - <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400"> 765 - <span class="iconify lucide--key-round text-xs"></span> 766 - <span>{detectKeyType(key())}</span> 767 - </div> 768 - </div> 769 - <span></span> 770 - <div class="font-mono break-all">{key()}</div> 771 - </div> 772 - )} 773 - </Show> 774 - )} 775 - </For> 776 - </div> 777 - </div> 778 - </Show> 779 - 780 - {/* Rotation Keys Section */} 781 - <Show when={rotationKeys().length > 0}> 782 - <div> 783 - <p class="font-semibold">Rotation Keys</p> 784 - <div class="flex flex-col gap-1"> 785 - <For each={rotationKeys()}> 786 - {(key) => ( 787 - <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 788 - <span class="iconify lucide--key-round"></span> 789 - <span>{detectDidKeyType(key)}</span> 790 - <span></span> 791 - <div class="font-mono break-all">{key.replace("did:key:", "")}</div> 792 - </div> 793 - )} 794 - </For> 795 - </div> 796 - </div> 797 - </Show> 798 - </div> 799 - )} 800 - </Show> 801 - </Show> 802 - </div> 803 - </div> 804 - </Show> 805 - 806 - <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 807 - <div class="dark:bg-dark-500 fixed bottom-0 z-10 flex w-full flex-col items-center gap-2 border-t border-neutral-200 bg-neutral-100 px-3 pt-3 pb-6 dark:border-neutral-700"> 808 - <div 809 - class="dark:bg-dark-200 flex w-full max-w-lg cursor-text items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 dark:border-neutral-700" 810 - onClick={(e) => { 811 - const input = e.currentTarget.querySelector("input"); 812 - if (e.target !== input) input?.focus(); 813 - }} 814 - > 815 - <span class="iconify lucide--filter text-neutral-500 dark:text-neutral-400"></span> 816 - <input 817 - ref={filterInputRef} 818 - type="text" 819 - spellcheck={false} 820 - autocapitalize="off" 821 - autocomplete="off" 822 - class="grow py-2 select-none placeholder:text-sm focus:outline-none" 823 - name="filter" 824 - placeholder="Filter collections..." 825 - value={filter() ?? ""} 826 - onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 827 - /> 828 - <Show when={canHover && !filter()}> 829 - <kbd class="rounded border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 font-mono text-xs text-neutral-400 select-none dark:border-neutral-600 dark:bg-neutral-700"> 830 - / 831 - </kbd> 832 - </Show> 833 - </div> 834 - <div class="flex w-full max-w-lg justify-end gap-1"> 835 - <button 836 - class="rounded px-2 py-1 text-xs text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 active:bg-neutral-300 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200 dark:active:bg-neutral-600" 837 - onClick={expandAll} 838 - > 839 - Expand all 840 - </button> 841 - <button 842 - class="rounded px-2 py-1 text-xs text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 active:bg-neutral-300 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200 dark:active:bg-neutral-600" 843 - onClick={collapseAll} 844 - > 845 - Collapse all 846 - </button> 847 - </div> 848 - </div> 849 - </Show> 850 - </> 851 - ); 852 - };
+251
src/views/repo/identity.tsx
··· 1 + import { DidDocument } from "@atcute/identity"; 2 + import { Did, Handle } from "@atcute/lexicons"; 3 + import { createSignal, For, Show } from "solid-js"; 4 + import { createStore } from "solid-js/store"; 5 + import { canHover } from "../../layout.jsx"; 6 + import { type HandleResolveResult, resolveHandleDetailed, validateHandle } from "../../lib/api.js"; 7 + import { detectDidKeyType, detectKeyType } from "../../lib/key.js"; 8 + import { addToClipboard } from "../../utils/copy.js"; 9 + 10 + const HandleResult = (props: { 11 + label: string; 12 + result: HandleResolveResult; 13 + expectedDid: string; 14 + }) => { 15 + const ok = () => props.result.success && props.result.did === props.expectedDid; 16 + const mismatch = () => props.result.success && props.result.did !== props.expectedDid; 17 + 18 + return ( 19 + <> 20 + <span 21 + classList={{ 22 + "iconify lucide--check text-green-600 dark:text-green-400": ok(), 23 + "iconify lucide--x text-red-500 dark:text-red-400": !ok(), 24 + }} 25 + ></span> 26 + <span class="font-medium">{props.label}</span> 27 + <span></span> 28 + <div class="mb-2 text-sm wrap-anywhere text-neutral-500 dark:text-neutral-400"> 29 + <Show 30 + when={props.result.success} 31 + fallback={<div class="text-red-500 dark:text-red-400">{props.result.error}</div>} 32 + > 33 + <div>{props.result.did}</div> 34 + <Show when={mismatch()}> 35 + <div class="text-red-500 dark:text-red-400">Expected: {props.expectedDid}</div> 36 + </Show> 37 + </Show> 38 + </div> 39 + </> 40 + ); 41 + }; 42 + 43 + const AliasEntry = (props: { alias: string; did: string; valid: boolean | undefined }) => { 44 + const [expanded, setExpanded] = createSignal(false); 45 + const [result, setResult] = createSignal<{ 46 + dns: HandleResolveResult; 47 + http: HandleResolveResult; 48 + } | null>(null); 49 + 50 + return ( 51 + <Show 52 + when={props.alias.startsWith("at://")} 53 + fallback={<div class="text-sm text-neutral-700 dark:text-neutral-300">{props.alias}</div>} 54 + > 55 + <div class="flex flex-col gap-2"> 56 + <button 57 + class="-ml-1 flex w-fit items-center gap-1 rounded px-1 py-0.5 text-left text-sm text-neutral-700 hover:bg-neutral-200 active:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-700" 58 + onClick={async () => { 59 + if (expanded()) { 60 + setExpanded(false); 61 + } else { 62 + setResult(null); 63 + setExpanded(true); 64 + const handle = props.alias.replace("at://", "") as Handle; 65 + const r = await resolveHandleDetailed(handle); 66 + if (expanded()) setResult(r); 67 + } 68 + }} 69 + > 70 + <span>{props.alias}</span> 71 + <span 72 + classList={{ 73 + "iconify text-base shrink-0 lucide--check text-green-600 dark:text-green-400": 74 + props.valid === true, 75 + "iconify lucide--x text-red-500 dark:text-red-400": props.valid === false, 76 + "iconify lucide--loader-circle animate-spin": props.valid === undefined, 77 + }} 78 + ></span> 79 + </button> 80 + 81 + <Show when={expanded()}> 82 + <div class="mb-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 dark:border-neutral-700 dark:bg-neutral-800/50"> 83 + <Show 84 + when={result()} 85 + fallback={ 86 + <div class="flex items-center gap-2 py-2 text-sm"> 87 + <span class="iconify lucide--loader-circle animate-spin"></span> 88 + <span>Resolving handle...</span> 89 + </div> 90 + } 91 + > 92 + {(r) => ( 93 + <div class="grid grid-cols-[auto_1fr] items-center gap-x-1.5 text-sm"> 94 + <HandleResult label="DNS (TXT record)" result={r().dns} expectedDid={props.did} /> 95 + <HandleResult 96 + label="HTTP (.well-known)" 97 + result={r().http} 98 + expectedDid={props.did} 99 + /> 100 + </div> 101 + )} 102 + </Show> 103 + </div> 104 + </Show> 105 + </div> 106 + </Show> 107 + ); 108 + }; 109 + 110 + const handleValidationCache = new Map<string, Record<string, boolean>>(); 111 + 112 + export const IdentityView = (props: { didDoc: DidDocument; rotationKeys: string[] }) => { 113 + const did = props.didDoc.id; 114 + const cached = handleValidationCache.get(did); 115 + const [validHandles, setValidHandles] = createStore<Record<string, boolean>>(cached ?? {}); 116 + 117 + if (!cached) { 118 + (async () => { 119 + for (const alias of props.didDoc.alsoKnownAs ?? []) { 120 + if (alias.startsWith("at://")) { 121 + const valid = await validateHandle(alias.replace("at://", "") as Handle, did as Did); 122 + setValidHandles(alias, valid); 123 + } 124 + } 125 + handleValidationCache.set(did, { ...validHandles }); 126 + })(); 127 + } 128 + 129 + return ( 130 + <div class="flex flex-col gap-3 wrap-anywhere"> 131 + {/* DID */} 132 + <div> 133 + <div class="font-semibold">DID</div> 134 + <button 135 + class="group flex w-full items-center gap-1 text-left text-sm text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-neutral-200" 136 + onClick={() => addToClipboard(did)} 137 + > 138 + <span class="truncate">{did}</span> 139 + <span 140 + classList={{ 141 + "iconify lucide--copy shrink-0": true, 142 + "opacity-0 group-hover:opacity-100": canHover, 143 + }} 144 + ></span> 145 + </button> 146 + </div> 147 + 148 + {/* Aliases */} 149 + <Show when={props.didDoc.alsoKnownAs}> 150 + <div> 151 + <p class="font-semibold">Aliases</p> 152 + <For each={props.didDoc.alsoKnownAs}> 153 + {(alias) => <AliasEntry alias={alias} did={did} valid={validHandles[alias]} />} 154 + </For> 155 + </div> 156 + </Show> 157 + 158 + {/* Services */} 159 + <Show when={props.didDoc.service}> 160 + <div> 161 + <p class="font-semibold">Services</p> 162 + <div class="flex flex-col gap-1"> 163 + <For each={props.didDoc.service}> 164 + {(service) => ( 165 + <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 166 + <span class="iconify lucide--hash"></span> 167 + <div class="flex items-center gap-2"> 168 + <span>{service.id.split("#")[1]}</span> 169 + <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400"> 170 + <span 171 + class="iconify text-xs" 172 + classList={{ 173 + "lucide--hard-drive": service.type === "AtprotoPersonalDataServer", 174 + "lucide--tag": service.type === "AtprotoLabeler", 175 + "lucide--rss": service.type === "BskyFeedGenerator", 176 + "lucide--wrench": ![ 177 + "AtprotoPersonalDataServer", 178 + "AtprotoLabeler", 179 + "BskyFeedGenerator", 180 + ].includes(service.type.toString()), 181 + }} 182 + ></span> 183 + <span>{service.type}</span> 184 + </div> 185 + </div> 186 + <span></span> 187 + <a 188 + class="w-fit underline hover:text-blue-500 dark:hover:text-blue-400" 189 + href={service.serviceEndpoint.toString()} 190 + target="_blank" 191 + rel="noopener" 192 + > 193 + {service.serviceEndpoint.toString()} 194 + </a> 195 + </div> 196 + )} 197 + </For> 198 + </div> 199 + </div> 200 + </Show> 201 + 202 + {/* Verification Methods */} 203 + <Show when={props.didDoc.verificationMethod}> 204 + <div> 205 + <p class="font-semibold">Verification Methods</p> 206 + <div class="flex flex-col gap-1"> 207 + <For each={props.didDoc.verificationMethod}> 208 + {(verif) => ( 209 + <Show when={verif.publicKeyMultibase}> 210 + {(key) => ( 211 + <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 212 + <span class="iconify lucide--hash"></span> 213 + <div class="flex items-center gap-2"> 214 + <span>{verif.id.split("#")[1]}</span> 215 + <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400"> 216 + <span class="iconify lucide--key-round text-xs"></span> 217 + <span>{detectKeyType(key())}</span> 218 + </div> 219 + </div> 220 + <span></span> 221 + <div class="font-mono break-all">{key()}</div> 222 + </div> 223 + )} 224 + </Show> 225 + )} 226 + </For> 227 + </div> 228 + </div> 229 + </Show> 230 + 231 + {/* Rotation Keys */} 232 + <Show when={props.rotationKeys.length > 0}> 233 + <div> 234 + <p class="font-semibold">Rotation Keys</p> 235 + <div class="flex flex-col gap-1"> 236 + <For each={props.rotationKeys}> 237 + {(key) => ( 238 + <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 239 + <span class="iconify lucide--key-round"></span> 240 + <span>{detectDidKeyType(key)}</span> 241 + <span></span> 242 + <div class="font-mono break-all">{key.replace("did:key:", "")}</div> 243 + </div> 244 + )} 245 + </For> 246 + </div> 247 + </div> 248 + </Show> 249 + </div> 250 + ); 251 + };
+620
src/views/repo/index.tsx
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { DidDocument } from "@atcute/identity"; 3 + import { ActorIdentifier, Handle, Nsid } from "@atcute/lexicons"; 4 + import { 5 + A, 6 + type RoutePreloadFunc, 7 + type RouteSectionProps, 8 + useLocation, 9 + useNavigate, 10 + useParams, 11 + } from "@solidjs/router"; 12 + import { createEffect, createResource, createSignal, For, onMount, Show } from "solid-js"; 13 + import { Backlinks } from "../../components/backlinks.jsx"; 14 + import { 15 + ActionMenu, 16 + DropdownMenu, 17 + MenuProvider, 18 + MenuSeparator, 19 + NavMenu, 20 + } from "../../components/dropdown.jsx"; 21 + import { Favicon } from "../../components/favicon.jsx"; 22 + import { LazyTab } from "../../components/lazy-tab.jsx"; 23 + import { setPDS } from "../../components/navbar.jsx"; 24 + import { NestedLayout } from "../../components/nested-layout.jsx"; 25 + import { 26 + addNotification, 27 + removeNotification, 28 + updateNotification, 29 + } from "../../components/notification.jsx"; 30 + import { Spinner } from "../../components/spinner.jsx"; 31 + import { canHover } from "../../layout.jsx"; 32 + import { 33 + didDocCache, 34 + getPDS, 35 + labelerCache, 36 + resolveHandle, 37 + resolveLexiconAuthority, 38 + } from "../../lib/api.js"; 39 + import { createLatch } from "../../lib/create-latch.js"; 40 + import { useFilterShortcut } from "../../lib/keyboard.js"; 41 + import { RepoProvider, useRepo } from "../../lib/repo-context.jsx"; 42 + import { BlobView } from "../blob.jsx"; 43 + import { IdentityView } from "./identity.jsx"; 44 + import { PlcLogView } from "./logs.jsx"; 45 + import { plcDirectory } from "../settings.jsx"; 46 + 47 + export const repoPreload: RoutePreloadFunc = ({ params }) => { 48 + if (params.repo?.startsWith("did:")) void getPDS(params.repo); 49 + }; 50 + 51 + export const RepoLayout = (props: RouteSectionProps) => { 52 + const params = useParams(); 53 + const location = useLocation(); 54 + const navigate = useNavigate(); 55 + const hasChild = () => !!params.collection; 56 + 57 + // Redirect non-DID identifiers (handles, NSIDs) via effect — must be separate from 58 + // the resource because navigate inside a resource fetcher doesn't reliably re-trigger it 59 + createEffect(() => { 60 + const identifier = params.repo; 61 + if (!identifier || identifier.startsWith("did:")) return; 62 + resolveHandle(identifier as Handle) 63 + .then((resolvedDid) => { 64 + navigate(location.pathname.replace(identifier, resolvedDid), { replace: true }); 65 + }) 66 + .catch(() => { 67 + resolveLexiconAuthority(identifier as Nsid) 68 + .then((authority) => { 69 + navigate(`/at://${authority}/com.atproto.lexicon.schema/${identifier}`, { 70 + replace: true, 71 + }); 72 + }) 73 + .catch(() => { 74 + navigate(`/${identifier}`, { replace: true }); 75 + }); 76 + }); 77 + }); 78 + 79 + // Resource only runs for DIDs — resolves PDS + creates RPC client 80 + const [resolution] = createResource( 81 + () => { 82 + const id = params.repo; 83 + return id?.startsWith("did:") ? id : undefined; 84 + }, 85 + async (did) => { 86 + try { 87 + const pdsUrl = await getPDS(did); 88 + const rpc = new Client({ handler: simpleFetchHandler({ service: pdsUrl }) }); 89 + const didDoc = didDocCache[did] as DidDocument | undefined; 90 + setPDS(pdsUrl.replace("https://", "").replace("http://", "")); 91 + return { did, pds: pdsUrl, rpc, didDoc }; 92 + } catch { 93 + let didDoc: DidDocument | undefined; 94 + if (did.startsWith("did:web")) { 95 + try { 96 + const res = await fetch(`https://${did.replace("did:web:", "")}/.well-known/did.json`); 97 + didDoc = await res.json(); 98 + } catch {} 99 + } 100 + setPDS("Missing PDS"); 101 + return { 102 + did, 103 + pds: undefined as string | undefined, 104 + rpc: undefined as Client | undefined, 105 + didDoc, 106 + error: "Missing PDS", 107 + }; 108 + } 109 + }, 110 + ); 111 + 112 + // Only expose data when resolution matches current params (prevents stale data during transitions) 113 + const current = () => { 114 + const r = resolution(); 115 + if (!r || r.did !== params.repo) return null; 116 + return r; 117 + }; 118 + 119 + return ( 120 + <RepoProvider 121 + value={{ 122 + did: () => params.repo!, 123 + pds: () => current()?.pds, 124 + rpc: () => current()?.rpc, 125 + didDoc: () => current()?.didDoc, 126 + error: () => current()?.error, 127 + }} 128 + > 129 + <NestedLayout key={params.repo} hasChild={hasChild()} view={() => <RepoView />}> 130 + {props.children} 131 + </NestedLayout> 132 + </RepoProvider> 133 + ); 134 + }; 135 + 136 + const downloadRepo = async (pdsUrl: string, did: string) => { 137 + let notificationId: string | null = null; 138 + const abortController = new AbortController(); 139 + 140 + try { 141 + notificationId = addNotification({ 142 + message: "Downloading repository...", 143 + progress: 0, 144 + total: 0, 145 + type: "info", 146 + onCancel: () => { 147 + abortController.abort(); 148 + if (notificationId) removeNotification(notificationId); 149 + }, 150 + }); 151 + 152 + const response = await fetch(`${pdsUrl}/xrpc/com.atproto.sync.getRepo?did=${did}`, { 153 + signal: abortController.signal, 154 + }); 155 + if (!response.ok) throw new Error(`HTTP error status: ${response.status}`); 156 + 157 + const contentLength = response.headers.get("content-length"); 158 + const total = contentLength ? parseInt(contentLength, 10) : 0; 159 + let loaded = 0; 160 + 161 + const reader = response.body?.getReader(); 162 + const chunks: BlobPart[] = []; 163 + 164 + if (reader) { 165 + while (true) { 166 + const { done, value } = await reader.read(); 167 + if (done) break; 168 + 169 + chunks.push(value); 170 + loaded += value.length; 171 + 172 + if (total > 0) { 173 + updateNotification(notificationId, { 174 + progress: Math.round((loaded / total) * 100), 175 + total, 176 + }); 177 + } else { 178 + updateNotification(notificationId, { 179 + progress: Math.round((loaded / (1024 * 1024)) * 10) / 10, 180 + total: 0, 181 + }); 182 + } 183 + } 184 + } 185 + 186 + const blob = new Blob(chunks); 187 + const url = window.URL.createObjectURL(blob); 188 + const a = document.createElement("a"); 189 + a.href = url; 190 + a.download = `${did}-${new Date().toISOString()}.car`; 191 + document.body.appendChild(a); 192 + a.click(); 193 + window.URL.revokeObjectURL(url); 194 + document.body.removeChild(a); 195 + 196 + updateNotification(notificationId, { 197 + message: "Repository downloaded successfully", 198 + type: "success", 199 + progress: undefined, 200 + onCancel: undefined, 201 + }); 202 + setTimeout(() => { 203 + if (notificationId) removeNotification(notificationId); 204 + }, 3000); 205 + } catch (error) { 206 + if (!(error instanceof Error && error.name === "AbortError")) { 207 + console.error("Download failed:", error); 208 + if (notificationId) { 209 + updateNotification(notificationId, { 210 + message: "Download failed", 211 + type: "error", 212 + progress: undefined, 213 + onCancel: undefined, 214 + }); 215 + setTimeout(() => { 216 + if (notificationId) removeNotification(notificationId); 217 + }, 5000); 218 + } 219 + } 220 + } 221 + }; 222 + 223 + const RepoView = () => { 224 + const repo = useRepo(); 225 + const params = useParams(); 226 + const hidden = () => !!params.collection; 227 + const location = useLocation(); 228 + const [error, setError] = createSignal<string>(); 229 + const [downloading, setDownloading] = createSignal(false); 230 + const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 231 + const [filter, setFilter] = createSignal<string>(); 232 + const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]); 233 + let filterInputRef: HTMLInputElement | undefined; 234 + const did = repo.did(); 235 + 236 + // Handle scrolling to a collection group when hash is like #collections:app.bsky 237 + createEffect(() => { 238 + const hash = location.hash; 239 + if (hash.startsWith("#collections:")) { 240 + const authority = hash.slice(13); 241 + requestAnimationFrame(() => { 242 + const element = document.getElementById(`collection-${authority}`); 243 + if (element) element.scrollIntoView({ behavior: "instant", block: "start" }); 244 + }); 245 + } 246 + }); 247 + 248 + onMount(() => { 249 + useFilterShortcut(() => filterInputRef); 250 + }); 251 + 252 + const RepoTab = (props: { 253 + tab: "collections" | "backlinks" | "identity" | "blobs" | "logs"; 254 + label: string; 255 + }) => { 256 + const isActive = () => { 257 + if (!location.hash) { 258 + if (!error() && props.tab === "collections") return true; 259 + if (!!error() && props.tab === "identity") return true; 260 + return false; 261 + } 262 + return location.hash.startsWith(`#${props.tab}`); 263 + }; 264 + 265 + return ( 266 + <A 267 + classList={{ 268 + "border-b-2 font-medium transition-colors": true, 269 + "border-transparent not-hover:text-neutral-600 not-hover:dark:text-neutral-300/80": 270 + !isActive(), 271 + }} 272 + href={`/at://${params.repo}#${props.tab}`} 273 + > 274 + {props.label} 275 + </A> 276 + ); 277 + }; 278 + 279 + const getRotationKeys = async () => { 280 + const res = await fetch(`${plcDirectory()}/${did}/log/last`); 281 + const json = await res.json(); 282 + setRotationKeys(json.rotationKeys ?? []); 283 + }; 284 + 285 + const fetchRepo = async () => { 286 + if (repo.error()) { 287 + setError(repo.error()!); 288 + } 289 + 290 + if (did.startsWith("did:plc")) getRotationKeys(); 291 + 292 + const rpc = repo.rpc(); 293 + if (!rpc) return {}; 294 + 295 + try { 296 + const res = await rpc.get("com.atproto.repo.describeRepo", { 297 + params: { repo: did as ActorIdentifier }, 298 + }); 299 + if (res.ok) { 300 + const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 301 + res.data.collections.forEach((c) => { 302 + const nsid = c.split("."); 303 + if (nsid.length > 2) { 304 + const authority = `${nsid[0]}.${nsid[1]}`; 305 + collections[authority] = { 306 + nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 307 + hidden: false, 308 + }; 309 + } 310 + }); 311 + setNsids(collections); 312 + } else { 313 + console.error(res.data.error); 314 + switch (res.data.error) { 315 + case "RepoDeactivated": 316 + setError("Deactivated"); 317 + break; 318 + case "RepoTakendown": 319 + setError("Taken down"); 320 + break; 321 + default: 322 + setError("Unreachable"); 323 + } 324 + } 325 + 326 + return res.data; 327 + } catch { 328 + return {}; 329 + } 330 + }; 331 + 332 + const shouldFetch = createLatch(() => !hidden() && (!!repo.rpc() || !!repo.error())); 333 + 334 + const [repoData] = createResource(shouldFetch, fetchRepo); 335 + 336 + const toggleCollapsed = (authority: string) => { 337 + setNsids((prev) => ({ 338 + ...prev!, 339 + [authority]: { ...prev![authority], hidden: !prev![authority].hidden }, 340 + })); 341 + }; 342 + 343 + const collapseAll = () => { 344 + setNsids((prev) => 345 + Object.fromEntries(Object.entries(prev!).map(([k, v]) => [k, { ...v, hidden: true }])), 346 + ); 347 + }; 348 + 349 + const expandAll = () => { 350 + setNsids((prev) => 351 + Object.fromEntries(Object.entries(prev!).map(([k, v]) => [k, { ...v, hidden: false }])), 352 + ); 353 + }; 354 + 355 + const handleDownload = async () => { 356 + const pdsUrl = repo.pds(); 357 + if (!pdsUrl) return; 358 + setDownloading(true); 359 + await downloadRepo(pdsUrl, did); 360 + setDownloading(false); 361 + }; 362 + 363 + createEffect(() => { 364 + if (hidden()) return; 365 + const handle = repo 366 + .didDoc() 367 + ?.alsoKnownAs?.find((alias) => alias.startsWith("at://")) 368 + ?.replace("at://", ""); 369 + document.title = handle ? `${handle} - PDSls` : `${params.repo} - PDSls`; 370 + }); 371 + 372 + return ( 373 + <Show when={!hidden()}> 374 + <Show when={repoData.state === "unresolved" || repoData.loading}> 375 + <Spinner /> 376 + </Show> 377 + <Show when={repoData.state === "ready" || repo.error()}> 378 + <div class="flex w-full flex-col gap-3 wrap-break-word"> 379 + <div class="flex justify-between px-2 text-sm sm:text-base"> 380 + <div class="flex items-center gap-3 sm:gap-4"> 381 + <Show when={!error()}> 382 + <RepoTab tab="collections" label="Collections" /> 383 + </Show> 384 + <RepoTab tab="identity" label="Identity" /> 385 + <Show when={did.startsWith("did:plc")}> 386 + <RepoTab tab="logs" label="Logs" /> 387 + </Show> 388 + <Show when={!error()}> 389 + <RepoTab tab="blobs" label="Blobs" /> 390 + </Show> 391 + <RepoTab tab="backlinks" label="Backlinks" /> 392 + </div> 393 + <div class="flex gap-1"> 394 + <Show when={error() && error() !== "Missing PDS"}> 395 + <div class="flex items-center gap-1 rounded-md border border-red-500 px-1.5 py-0.5 text-xs font-medium text-red-500 sm:text-sm dark:border-red-400 dark:text-red-400"> 396 + <span 397 + class={`iconify ${ 398 + error() === "Deactivated" ? "lucide--user-round-x" 399 + : error() === "Taken down" ? "lucide--shield-ban" 400 + : "lucide--unplug" 401 + }`} 402 + ></span> 403 + <span>{error()}</span> 404 + </div> 405 + </Show> 406 + <MenuProvider> 407 + <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5"> 408 + <NavMenu 409 + href={`/jetstream?dids=${params.repo}`} 410 + label="Jetstream" 411 + icon="lucide--radio-tower" 412 + /> 413 + <Show when={params.repo && params.repo in labelerCache}> 414 + <NavMenu 415 + href={`/labels?did=${params.repo}&uriPatterns=*`} 416 + label="Labels" 417 + icon="lucide--tag" 418 + /> 419 + </Show> 420 + <Show when={error()?.length === 0 || error() === undefined}> 421 + <ActionMenu 422 + label="Download repo" 423 + icon={ 424 + downloading() ? "lucide--loader-circle animate-spin" : "lucide--download" 425 + } 426 + onClick={handleDownload} 427 + /> 428 + </Show> 429 + <MenuSeparator /> 430 + <NavMenu 431 + href={ 432 + did.startsWith("did:plc") ? 433 + `${plcDirectory()}/${did}` 434 + : `https://${did.split("did:web:")[1]}/.well-known/did.json` 435 + } 436 + newTab 437 + label="DID document" 438 + icon="lucide--external-link" 439 + /> 440 + <Show when={did.startsWith("did:plc")}> 441 + <NavMenu 442 + href={`${plcDirectory()}/${did}/log/audit`} 443 + newTab 444 + label="Audit log" 445 + icon="lucide--external-link" 446 + /> 447 + </Show> 448 + </DropdownMenu> 449 + </MenuProvider> 450 + </div> 451 + </div> 452 + <div class="flex w-full flex-col gap-1 px-2"> 453 + <Show when={location.hash.startsWith("#logs")}> 454 + <LazyTab> 455 + <PlcLogView did={did} /> 456 + </LazyTab> 457 + </Show> 458 + <Show when={location.hash === "#backlinks"}> 459 + <LazyTab> 460 + <Backlinks target={did} /> 461 + </LazyTab> 462 + </Show> 463 + <Show when={location.hash === "#blobs"}> 464 + <LazyTab> 465 + <BlobView pds={repo.pds()!} repo={did} /> 466 + </LazyTab> 467 + </Show> 468 + <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 469 + <div class="flex flex-col pb-20 text-sm wrap-anywhere"> 470 + <Show 471 + when={Object.keys(nsids() ?? {}).length != 0} 472 + fallback={<span class="mt-3 text-center text-base">No collections found.</span>} 473 + > 474 + <For 475 + each={Object.keys(nsids() ?? {}).filter((authority) => 476 + filter() ? 477 + authority.includes(filter()!) || 478 + nsids()?.[authority].nsids.some((nsid) => 479 + `${authority}.${nsid}`.includes(filter()!), 480 + ) 481 + : true, 482 + )} 483 + > 484 + {(authority) => { 485 + const isHighlighted = () => location.hash === `#collections:${authority}`; 486 + const isCollapsed = () => nsids()?.[authority].hidden ?? false; 487 + 488 + return ( 489 + <div 490 + id={`collection-${authority}`} 491 + class="group relative flex scroll-mt-4 items-start gap-2 rounded-lg p-1 transition-colors" 492 + classList={{ 493 + "dark:hover:bg-dark-300 hover:bg-neutral-200": !isHighlighted(), 494 + "bg-blue-100 dark:bg-blue-500/25": isHighlighted(), 495 + }} 496 + > 497 + <Favicon 498 + domain={authority} 499 + reverse 500 + wrapper={(children) => ( 501 + <a 502 + href={`#collections:${authority}`} 503 + class="relative flex h-5 w-4 shrink-0 items-center justify-center hover:opacity-70" 504 + > 505 + <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"> 506 + <span class="iconify lucide--link absolute -left-2 w-7"></span> 507 + </span> 508 + {children} 509 + </a> 510 + )} 511 + /> 512 + <Show 513 + when={!isCollapsed()} 514 + fallback={ 515 + <button 516 + class="flex flex-1 items-center text-left" 517 + onClick={() => toggleCollapsed(authority)} 518 + > 519 + <span class="text-neutral-700 dark:text-neutral-300"> 520 + {authority} 521 + </span> 522 + <span class="text-neutral-500 dark:text-neutral-400">.*</span> 523 + <span class="ml-1.5 text-neutral-400 dark:text-neutral-500"> 524 + ({nsids()?.[authority].nsids.length}) 525 + </span> 526 + </button> 527 + } 528 + > 529 + <div class="flex min-w-0 flex-1 flex-col"> 530 + <For 531 + each={nsids()?.[authority].nsids.filter((nsid) => 532 + filter() ? `${authority}.${nsid}`.includes(filter()!) : true, 533 + )} 534 + > 535 + {(nsid, index) => ( 536 + <A 537 + href={`/at://${did}/${authority}.${nsid}`} 538 + class="truncate hover:underline active:underline" 539 + classList={{ "pr-16": canHover && index() === 0 }} 540 + > 541 + <span class="text-neutral-800/70 dark:text-neutral-200/70"> 542 + {authority}. 543 + </span> 544 + <span>{nsid}</span> 545 + </A> 546 + )} 547 + </For> 548 + </div> 549 + </Show> 550 + <Show when={canHover}> 551 + <button 552 + class="absolute top-1 right-1 rounded px-2 py-0.5 text-xs text-neutral-500 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-neutral-300 hover:text-neutral-700 active:bg-neutral-400 dark:text-neutral-400 dark:hover:bg-neutral-600 dark:hover:text-neutral-200 dark:active:bg-neutral-500" 553 + onClick={() => toggleCollapsed(authority)} 554 + > 555 + {isCollapsed() ? "expand" : "collapse"} 556 + </button> 557 + </Show> 558 + </div> 559 + ); 560 + }} 561 + </For> 562 + </Show> 563 + </div> 564 + </Show> 565 + <Show when={location.hash === "#identity" || (error() && !location.hash)}> 566 + <Show when={repo.didDoc()}> 567 + {(didDoc) => <IdentityView didDoc={didDoc()} rotationKeys={rotationKeys()} />} 568 + </Show> 569 + </Show> 570 + </div> 571 + </div> 572 + 573 + <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 574 + <div class="dark:bg-dark-500 fixed bottom-0 z-10 flex w-full flex-col items-center gap-2 border-t border-neutral-200 bg-neutral-100 px-3 pt-3 pb-6 dark:border-neutral-700"> 575 + <div 576 + class="dark:bg-dark-200 flex w-full max-w-lg cursor-text items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 dark:border-neutral-700" 577 + onClick={(e) => { 578 + const input = e.currentTarget.querySelector("input"); 579 + if (e.target !== input) input?.focus(); 580 + }} 581 + > 582 + <span class="iconify lucide--filter text-neutral-500 dark:text-neutral-400"></span> 583 + <input 584 + ref={filterInputRef} 585 + type="text" 586 + spellcheck={false} 587 + autocapitalize="off" 588 + autocomplete="off" 589 + class="grow py-2 select-none placeholder:text-sm focus:outline-none" 590 + name="filter" 591 + placeholder="Filter collections..." 592 + value={filter() ?? ""} 593 + onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 594 + /> 595 + <Show when={canHover && !filter()}> 596 + <kbd class="rounded border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 font-mono text-xs text-neutral-400 select-none dark:border-neutral-600 dark:bg-neutral-700"> 597 + / 598 + </kbd> 599 + </Show> 600 + </div> 601 + <div class="flex w-full max-w-lg justify-end gap-1"> 602 + <button 603 + class="rounded px-2 py-1 text-xs text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 active:bg-neutral-300 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200 dark:active:bg-neutral-600" 604 + onClick={expandAll} 605 + > 606 + Expand all 607 + </button> 608 + <button 609 + class="rounded px-2 py-1 text-xs text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 active:bg-neutral-300 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200 dark:active:bg-neutral-600" 610 + onClick={collapseAll} 611 + > 612 + Collapse all 613 + </button> 614 + </div> 615 + </div> 616 + </Show> 617 + </Show> 618 + </Show> 619 + ); 620 + };