1import { Client, CredentialManager } from "@atcute/client";
2import { lexiconDoc } from "@atcute/lexicon-doc";
3import { ResolvedSchema } from "@atcute/lexicon-resolver";
4import { ActorIdentifier, is, Nsid, ResourceUri } from "@atcute/lexicons";
5import { AtprotoDid, Did } from "@atcute/lexicons/syntax";
6import { verifyRecord } from "@atcute/repo";
7import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
8import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js";
9import { Backlinks } from "../components/backlinks.jsx";
10import { Button } from "../components/button.jsx";
11import { RecordEditor, setPlaceholder } from "../components/create.jsx";
12import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx";
13import { JSONValue } from "../components/json.jsx";
14import { LexiconSchemaView } from "../components/lexicon-schema.jsx";
15import { agent } from "../components/login.jsx";
16import { Modal } from "../components/modal.jsx";
17import { pds } from "../components/navbar.jsx";
18import Tooltip from "../components/tooltip.jsx";
19import { setNotif } from "../layout.jsx";
20import { resolveLexiconAuthority, resolveLexiconSchema, resolvePDS } from "../utils/api.js";
21import { AtUri, uriTemplates } from "../utils/templates.js";
22import { lexicons } from "../utils/types/lexicons.js";
23
24export const RecordView = () => {
25 const location = useLocation();
26 const navigate = useNavigate();
27 const params = useParams();
28 const [openDelete, setOpenDelete] = createSignal(false);
29 const [notice, setNotice] = createSignal("");
30 const [externalLink, setExternalLink] = createSignal<
31 { label: string; link: string; icon?: string } | undefined
32 >();
33 const [lexiconUri, setLexiconUri] = createSignal<string>();
34 const [validRecord, setValidRecord] = createSignal<boolean | undefined>(undefined);
35 const [validSchema, setValidSchema] = createSignal<boolean | undefined>(undefined);
36 const [schema, setSchema] = createSignal<ResolvedSchema>();
37 const [lexiconNotFound, setLexiconNotFound] = createSignal<boolean>();
38 const did = params.repo;
39 let rpc: Client;
40
41 const fetchRecord = async () => {
42 setValidRecord(undefined);
43 setValidSchema(undefined);
44 setLexiconUri(undefined);
45 const pds = await resolvePDS(did);
46 rpc = new Client({ handler: new CredentialManager({ service: pds }) });
47 const res = await rpc.get("com.atproto.repo.getRecord", {
48 params: {
49 repo: did as ActorIdentifier,
50 collection: params.collection as `${string}.${string}.${string}`,
51 rkey: params.rkey,
52 },
53 });
54 if (!res.ok) {
55 setValidRecord(false);
56 setNotice(res.data.error);
57 throw new Error(res.data.error);
58 }
59 setPlaceholder(res.data.value);
60 setExternalLink(checkUri(res.data.uri, res.data.value));
61 resolveLexicon(params.collection as Nsid);
62 verify(res.data);
63
64 return res.data;
65 };
66
67 const [record, { refetch }] = createResource(fetchRecord);
68
69 const verify = async (record: {
70 uri: ResourceUri;
71 value: Record<string, unknown>;
72 cid?: string | undefined;
73 }) => {
74 try {
75 if (params.collection in lexicons) {
76 if (is(lexicons[params.collection], record.value)) setValidSchema(true);
77 else setValidSchema(false);
78 } else if (params.collection === "com.atproto.lexicon.schema") {
79 setLexiconNotFound(false);
80 try {
81 lexiconDoc.parse(record.value, { mode: "passthrough" });
82 setValidSchema(true);
83 } catch (e) {
84 console.error(e);
85 setValidSchema(false);
86 }
87 }
88
89 const { ok, data } = await rpc.get("com.atproto.sync.getRecord", {
90 params: {
91 did: did as Did,
92 collection: params.collection as Nsid,
93 rkey: params.rkey,
94 },
95 as: "bytes",
96 });
97 if (!ok) throw data.error;
98
99 await verifyRecord({
100 did: did as AtprotoDid,
101 collection: params.collection,
102 rkey: params.rkey,
103 carBytes: data,
104 });
105
106 setValidRecord(true);
107 } catch (err: any) {
108 console.error(err);
109 setNotice(err.message);
110 setValidRecord(false);
111 }
112 };
113
114 const resolveLexicon = async (nsid: Nsid) => {
115 try {
116 const authority = await resolveLexiconAuthority(nsid);
117 setLexiconUri(`at://${authority}/com.atproto.lexicon.schema/${nsid}`);
118 if (params.collection !== "com.atproto.lexicon.schema") {
119 const schema = await resolveLexiconSchema(authority, nsid);
120 setSchema(schema);
121 setLexiconNotFound(false);
122 }
123 } catch {
124 setLexiconNotFound(true);
125 }
126 };
127
128 const deleteRecord = async () => {
129 rpc = new Client({ handler: agent()! });
130 await rpc.post("com.atproto.repo.deleteRecord", {
131 input: {
132 repo: params.repo as ActorIdentifier,
133 collection: params.collection as `${string}.${string}.${string}`,
134 rkey: params.rkey,
135 },
136 });
137 setNotif({ show: true, icon: "lucide--trash-2", text: "Record deleted" });
138 navigate(`/at://${params.repo}/${params.collection}`);
139 };
140
141 const checkUri = (uri: string, record: any) => {
142 const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"]
143 if (uriParts.length != 5) return undefined;
144 if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined;
145 const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] };
146 const template = uriTemplates[parsedUri.collection];
147 if (!template) return undefined;
148 return template(parsedUri, record);
149 };
150
151 const RecordTab = (props: {
152 tab: "record" | "backlinks" | "info" | "schema";
153 label: string;
154 error?: boolean;
155 }) => {
156 const isActive = () => {
157 if (!location.hash && props.tab === "record") return true;
158 if (location.hash === `#${props.tab}`) return true;
159 if (props.tab === "schema" && location.hash.startsWith("#schema:")) return true;
160 return false;
161 };
162
163 return (
164 <div class="flex items-center gap-0.5">
165 <A
166 classList={{
167 "flex items-center gap-1 border-b-2": true,
168 "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600":
169 !isActive(),
170 }}
171 href={`/at://${did}/${params.collection}/${params.rkey}#${props.tab}`}
172 >
173 {props.label}
174 </A>
175 <Show when={props.error && (validRecord() === false || validSchema() === false)}>
176 <span class="iconify lucide--x text-red-500 dark:text-red-400"></span>
177 </Show>
178 </div>
179 );
180 };
181
182 return (
183 <Show when={record()} keyed>
184 <div class="flex w-full flex-col items-center">
185 <div class="dark:shadow-dark-700 dark:bg-dark-300 mb-3 flex w-full justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm shadow-xs dark:border-neutral-700">
186 <div class="flex gap-3">
187 <RecordTab tab="record" label="Record" />
188 <RecordTab tab="schema" label="Schema" />
189 <RecordTab tab="backlinks" label="Backlinks" />
190 <RecordTab tab="info" label="Info" error />
191 </div>
192 <div class="flex gap-1">
193 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}>
194 <RecordEditor create={false} record={record()?.value} refetch={refetch} />
195 <Tooltip text="Delete">
196 <button
197 class="flex items-center rounded-sm p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
198 onclick={() => setOpenDelete(true)}
199 >
200 <span class="iconify lucide--trash-2"></span>
201 </button>
202 </Tooltip>
203 <Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
204 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
205 <h2 class="mb-2 font-semibold">Delete this record?</h2>
206 <div class="flex justify-end gap-2">
207 <Button onClick={() => setOpenDelete(false)}>Cancel</Button>
208 <Button
209 onClick={deleteRecord}
210 class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400"
211 >
212 Delete
213 </Button>
214 </div>
215 </div>
216 </Modal>
217 </Show>
218 <MenuProvider>
219 <DropdownMenu
220 icon="lucide--ellipsis-vertical"
221 buttonClass="rounded-sm p-1"
222 menuClass="top-8 p-2 text-sm"
223 >
224 <CopyMenu
225 content={JSON.stringify(record()?.value, null, 2)}
226 label="Copy record"
227 icon="lucide--copy"
228 />
229 <CopyMenu
230 content={`at://${params.repo}/${params.collection}/${params.rkey}`}
231 label="Copy AT URI"
232 icon="lucide--copy"
233 />
234 <Show when={record()?.cid}>
235 {(cid) => <CopyMenu content={cid()} label="Copy CID" icon="lucide--copy" />}
236 </Show>
237 <Show when={externalLink()}>
238 {(externalLink) => (
239 <NavMenu
240 href={externalLink()?.link}
241 icon={`${externalLink().icon ?? "lucide--app-window"}`}
242 label={`Open on ${externalLink().label}`}
243 newTab
244 />
245 )}
246 </Show>
247 <NavMenu
248 href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`}
249 icon="lucide--external-link"
250 label="Record on PDS"
251 newTab
252 />
253 </DropdownMenu>
254 </MenuProvider>
255 </div>
256 </div>
257 <Show when={!location.hash || location.hash === "#record"}>
258 <div class="w-max max-w-screen min-w-full px-4 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:px-2 sm:text-sm md:max-w-3xl">
259 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} />
260 </div>
261 </Show>
262 <Show when={location.hash === "#schema" || location.hash.startsWith("#schema:")}>
263 <Show when={lexiconNotFound() === true}>
264 <span class="w-full px-2 text-sm">Lexicon schema could not be resolved.</span>
265 </Show>
266 <Show when={lexiconNotFound() === undefined}>
267 <span class="w-full px-2 text-sm">Resolving lexicon schema...</span>
268 </Show>
269 <Show when={schema() || params.collection === "com.atproto.lexicon.schema"}>
270 <ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
271 <LexiconSchemaView schema={schema()?.rawSchema ?? (record()?.value as any)} />
272 </ErrorBoundary>
273 </Show>
274 </Show>
275 <Show when={location.hash === "#backlinks"}>
276 <ErrorBoundary
277 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
278 >
279 <Suspense
280 fallback={
281 <div class="iconify lucide--loader-circle animate-spin self-center text-xl" />
282 }
283 >
284 <div class="w-full px-2">
285 <Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} />
286 </div>
287 </Suspense>
288 </ErrorBoundary>
289 </Show>
290 <Show when={location.hash === "#info"}>
291 <div class="flex w-full flex-col gap-2 px-2 text-sm">
292 <div>
293 <div class="flex items-center gap-1">
294 <span class="iconify lucide--at-sign"></span>
295 <p class="font-semibold">AT URI</p>
296 </div>
297 <div class="truncate text-xs">{record()?.uri}</div>
298 </div>
299 <Show when={record()?.cid}>
300 <div>
301 <div class="flex items-center gap-1">
302 <span class="iconify lucide--box"></span>
303 <p class="font-semibold">CID</p>
304 </div>
305 <div class="truncate text-left text-xs" dir="rtl">
306 {record()?.cid}
307 </div>
308 </div>
309 </Show>
310 <div>
311 <div class="flex items-center gap-1">
312 <span class="iconify lucide--lock-keyhole"></span>
313 <p class="font-semibold">Record verification</p>
314 <span
315 classList={{
316 "iconify lucide--check text-green-500 dark:text-green-400":
317 validRecord() === true,
318 "iconify lucide--x text-red-500 dark:text-red-400": validRecord() === false,
319 "iconify lucide--loader-circle animate-spin": validRecord() === undefined,
320 }}
321 ></span>
322 </div>
323 <Show when={validRecord() === false}>
324 <div class="wrap-break-word">{notice()}</div>
325 </Show>
326 </div>
327 <Show when={validSchema() !== undefined}>
328 <div class="flex items-center gap-1">
329 <span class="iconify lucide--file-check"></span>
330 <p class="font-semibold">Schema validation</p>
331 <span
332 class={`iconify ${validSchema() ? "lucide--check text-green-500 dark:text-green-400" : "lucide--x text-red-500 dark:text-red-400"}`}
333 ></span>
334 </div>
335 </Show>
336 <Show when={lexiconUri()}>
337 <div>
338 <div class="flex items-center gap-1">
339 <span class="iconify lucide--scroll-text"></span>
340 <p class="font-semibold">Lexicon schema</p>
341 </div>
342 <div class="truncate text-xs">
343 <A
344 href={`/${lexiconUri()}`}
345 class="text-blue-400 hover:underline active:underline"
346 >
347 {lexiconUri()}
348 </A>
349 </div>
350 </div>
351 </Show>
352 </div>
353 </Show>
354 </div>
355 </Show>
356 );
357};