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