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