import { Client, simpleFetchHandler } from "@atcute/client";
import { DidDocument, getPdsEndpoint } from "@atcute/identity";
import { lexiconDoc } from "@atcute/lexicon-doc";
import { RecordValidator } from "@atcute/lexicon-doc/validations";
import { FailedLexiconResolutionError, ResolvedSchema } from "@atcute/lexicon-resolver";
import { ActorIdentifier, is, Nsid } from "@atcute/lexicons";
import { AtprotoDid, Did, isNsid } from "@atcute/lexicons/syntax";
import { verifyRecord } from "@atcute/repo";
import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
import { createResource, createSignal, ErrorBoundary, For, Show, Suspense } from "solid-js";
import { agent } from "../auth/state";
import { Backlinks } from "../components/backlinks.jsx";
import { Button } from "../components/button.jsx";
import { RecordEditor, setPlaceholder } from "../components/create";
import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx";
import { Favicon } from "../components/favicon.jsx";
import { JSONValue } from "../components/json.jsx";
import { LexiconSchemaView } from "../components/lexicon-schema.jsx";
import { Modal } from "../components/modal.jsx";
import { addNotification, removeNotification } from "../components/notification.jsx";
import { PermissionButton } from "../components/permission-button.jsx";
import { canHover } from "../layout.jsx";
import { useRepo } from "../lib/repo-context.jsx";
import {
didDocumentResolver,
resolveLexiconAuthority,
resolveLexiconSchema,
} from "../lib/api.js";
import { addToClipboard } from "../utils/copy.js";
import { AtUri, uriTemplates } from "../lib/templates.js";
import { lexicons } from "../lib/types/lexicons.js";
const faviconWrapper = (children: any) => (
{children}
);
const bskyAltClients = [
{
label: "Blacksky",
hostname: "blacksky.app",
transform: (url: string) => url.replace("https://bsky.app", "https://blacksky.community"),
},
{
label: "Witchsky",
hostname: "witchsky.app",
transform: (url: string) => url.replace("https://bsky.app", "https://witchsky.app"),
},
{
label: "Red Dwarf",
hostname: "reddwarf.app",
transform: (url: string) => url.replace("https://bsky.app", "https://reddwarf.app"),
},
{
label: "Anartia",
hostname: "kelinci.net",
icon: "https://kelinci.net/rabbit.svg",
transform: (url: string) =>
url
.replace("https://bsky.app/profile", "https://anartia.kelinci.net")
.replace("/post/", "/")
.replace("/feed/", "/feeds/"),
},
];
const authorityCache = new Map>();
const documentCache = new Map>();
const schemaCache = new Map>();
const getAuthoritySegment = (nsid: string): string => {
const segments = nsid.split(".");
return segments.slice(0, -1).join(".");
};
const resolveSchema = async (authority: AtprotoDid, nsid: Nsid): Promise => {
const cacheKey = `${authority}:${nsid}`;
let cachedSchema = schemaCache.get(cacheKey);
if (cachedSchema) {
return cachedSchema;
}
const schemaPromise = (async () => {
let didDocPromise = documentCache.get(authority);
if (!didDocPromise) {
didDocPromise = didDocumentResolver().resolve(authority);
documentCache.set(authority, didDocPromise);
}
const didDocument = await didDocPromise;
const pdsEndpoint = getPdsEndpoint(didDocument);
if (!pdsEndpoint) {
throw new FailedLexiconResolutionError(nsid, {
cause: new TypeError(`no pds service in did document; did=${authority}`),
});
}
const rpc = new Client({ handler: simpleFetchHandler({ service: pdsEndpoint }) });
const response = await rpc.get("com.atproto.repo.getRecord", {
params: {
repo: authority,
collection: "com.atproto.lexicon.schema",
rkey: nsid,
},
});
if (!response.ok) {
throw new Error(`got http ${response.status}`);
}
return response.data.value;
})();
schemaCache.set(cacheKey, schemaPromise);
try {
return await schemaPromise;
} catch (err) {
schemaCache.delete(cacheKey);
throw err;
}
};
const extractRefs = (obj: any): Nsid[] => {
const refs: Set = new Set();
const traverse = (value: any) => {
if (!value || typeof value !== "object") return;
if (value.type === "ref" && value.ref) {
const ref = value.ref;
if (!ref.startsWith("#")) {
const nsid = ref.split("#")[0];
if (isNsid(nsid)) refs.add(nsid);
}
}
if (value.type === "union" && Array.isArray(value.refs)) {
for (const ref of value.refs) {
if (!ref.startsWith("#")) {
const nsid = ref.split("#")[0];
if (isNsid(nsid)) refs.add(nsid);
}
}
}
if (Array.isArray(value)) value.forEach(traverse);
else Object.values(value).forEach(traverse);
};
traverse(obj);
return Array.from(refs) as Nsid[];
};
const resolveAllLexicons = async (
nsid: Nsid,
depth: number = 0,
resolved: Map = new Map(),
failed: Set = new Set(),
inFlight: Map> = new Map(),
): Promise<{ resolved: Map; failed: Set }> => {
if (depth >= 10) {
console.warn(`Maximum recursion depth reached for ${nsid}`);
return { resolved, failed };
}
if (resolved.has(nsid) || failed.has(nsid)) return { resolved, failed };
if (inFlight.has(nsid)) {
await inFlight.get(nsid);
return { resolved, failed };
}
const fetchPromise = (async () => {
let authority: AtprotoDid | undefined;
const authoritySegment = getAuthoritySegment(nsid);
try {
let authorityPromise = authorityCache.get(authoritySegment);
if (!authorityPromise) {
authorityPromise = resolveLexiconAuthority(nsid);
authorityCache.set(authoritySegment, authorityPromise);
}
authority = await authorityPromise;
const schema = await resolveSchema(authority, nsid);
resolved.set(nsid, schema);
const refs = extractRefs(schema);
if (refs.length > 0) {
await Promise.all(
refs.map((ref) => resolveAllLexicons(ref, depth + 1, resolved, failed, inFlight)),
);
}
} catch (err) {
console.error(`Failed to resolve lexicon ${nsid}:`, err);
failed.add(nsid);
authorityCache.delete(authoritySegment);
if (authority) {
documentCache.delete(authority);
}
} finally {
inFlight.delete(nsid);
}
})();
inFlight.set(nsid, fetchPromise);
await fetchPromise;
return { resolved, failed };
};
export const RecordView = () => {
const repo = useRepo();
const location = useLocation();
const navigate = useNavigate();
const params = useParams();
const [openDelete, setOpenDelete] = createSignal(false);
const [showAlternates, setShowAlternates] = createSignal(false);
const [verifyError, setVerifyError] = createSignal("");
const [validationError, setValidationError] = createSignal("");
const [externalLink, setExternalLink] = createSignal<
{ label: string; link: string; icon?: string } | undefined
>();
const [lexiconAuthority, setLexiconAuthority] = createSignal();
const [validRecord, setValidRecord] = createSignal(undefined);
const [validSchema, setValidSchema] = createSignal(undefined);
const [schema, setSchema] = createSignal();
const [lexiconNotFound, setLexiconNotFound] = createSignal();
const [remoteValidation, setRemoteValidation] = createSignal();
const did = repo.did();
const fetchRecord = async () => {
const rpc = repo.rpc()!;
const collection = params.collection!;
const rkey = params.rkey!;
setValidRecord(undefined);
setValidSchema(undefined);
const res = await rpc.get("com.atproto.repo.getRecord", {
params: {
repo: did as ActorIdentifier,
collection: collection as `${string}.${string}.${string}`,
rkey,
},
});
if (!res.ok) {
setValidRecord(false);
setVerifyError(res.data.error);
throw new Error(res.data.error);
}
setPlaceholder(res.data.value);
setExternalLink(checkUri(res.data.uri, res.data.value));
resolveLexicon(collection as Nsid, collection);
verifyRecordIntegrity(rpc, collection, rkey);
validateLocalSchema(collection, res.data.value);
return res.data;
};
const [record, { refetch }] = createResource(
() => (repo.rpc() ? params.rkey : undefined),
fetchRecord,
);
const validateLocalSchema = async (collection: string, record: Record) => {
try {
if (collection === "com.atproto.lexicon.schema") {
setLexiconNotFound(false);
lexiconDoc.parse(record, { mode: "passthrough" });
setValidSchema(true);
} else if (collection in lexicons) {
if (is(lexicons[collection], record)) setValidSchema(true);
else setValidSchema(false);
}
} catch (err: any) {
console.error("Schema validation error:", err);
setValidSchema(false);
setValidationError(err.message || String(err));
}
};
const validateRemoteSchema = async (record: Record) => {
const collection = params.collection!;
const rkey = params.rkey!;
try {
setRemoteValidation(true);
const { resolved, failed } = await resolveAllLexicons(collection as Nsid);
if (failed.size > 0) {
console.error(`Failed to resolve ${failed.size} documents:`, Array.from(failed));
setValidSchema(false);
setValidationError(`Unable to resolve lexicon documents: ${Array.from(failed).join(", ")}`);
return;
}
const lexiconDocs = Object.fromEntries(resolved);
const validator = new RecordValidator(lexiconDocs, collection as Nsid);
validator.parse({
key: rkey ?? null,
object: record,
});
setValidSchema(true);
} catch (err: any) {
console.error("Schema validation error:", err);
setValidSchema(false);
setValidationError(err.message || String(err));
}
setRemoteValidation(false);
};
const verifyRecordIntegrity = async (rpc: Client, collection: string, rkey: string) => {
try {
const { ok, data } = await rpc.get("com.atproto.sync.getRecord", {
params: {
did: did as Did,
collection: collection as Nsid,
rkey,
},
as: "bytes",
});
if (!ok) throw data.error;
await verifyRecord({
did: did as AtprotoDid,
collection,
rkey,
carBytes: data as Uint8Array,
});
setValidRecord(true);
} catch (err: any) {
console.error("Record verification error:", err);
setVerifyError(err.message);
setValidRecord(false);
}
};
const resolveLexicon = async (nsid: Nsid, collection: string) => {
try {
const authority = await resolveLexiconAuthority(nsid);
setLexiconAuthority(authority);
if (collection !== "com.atproto.lexicon.schema") {
const schema = await resolveLexiconSchema(authority, nsid);
setSchema(schema);
setLexiconNotFound(false);
}
} catch {
setLexiconNotFound(true);
}
};
const deleteRecord = async () => {
const collection = params.collection!;
const rkey = params.rkey!;
const authRpc = new Client({ handler: agent()! });
await authRpc.post("com.atproto.repo.deleteRecord", {
input: {
repo: params.repo as ActorIdentifier,
collection: collection as `${string}.${string}.${string}`,
rkey,
},
});
const id = addNotification({
message: "Record deleted",
type: "success",
});
setTimeout(() => removeNotification(id), 3000);
navigate(`/at://${params.repo}/${params.collection}`);
};
const checkUri = (uri: string, record: any) => {
const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"]
if (uriParts.length != 5) return undefined;
if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined;
const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] };
const template = uriTemplates[parsedUri.collection];
if (!template) return undefined;
return template(parsedUri, record);
};
const RecordTab = (props: {
tab: "record" | "backlinks" | "info" | "schema";
label: string;
error?: boolean;
}) => {
const isActive = () => {
if (props.tab === "record") return !location.hash || location.hash.startsWith("#record");
if (location.hash === `#${props.tab}`) return true;
if (props.tab === "schema" && location.hash.startsWith("#schema:")) return true;
return false;
};
return (
);
};
document.title = `${params.collection}/${params.rkey} - PDSls`;
return (
<>
(
Error loading record
{err.message}
)}
>
setOpenDelete(true)}
>
setOpenDelete(false)}
contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700"
>
Delete this record?
{(link) => {
const bskyAlts = () =>
link().link.startsWith("https://bsky.app") ?
bskyAltClients.map((alt) => ({ ...alt, link: alt.transform(link().link) }))
: [];
return (
setShowAlternates(true)}
onMouseLeave={() => setShowAlternates(false)}
>
0,
}}
>
0}>
);
}}
Lexicon schema could not be resolved.
Resolving lexicon schema...
Error: {err.message}
}>
Error: {err.message}
}
>
}
>
AT URI
{(cid) => (
CID
)}
{validationError()}
>
);
};