1import { isCid, isDid, isNsid, isResourceUri, Nsid } from "@atcute/lexicons/syntax";
2import { A, useNavigate, useParams } from "@solidjs/router";
3import {
4 createContext,
5 createEffect,
6 createResource,
7 createSignal,
8 ErrorBoundary,
9 For,
10 on,
11 Show,
12 useContext,
13} from "solid-js";
14import { resolveLexiconAuthority } from "../utils/api";
15import { formatFileSize } from "../utils/format";
16import { hideMedia } from "../views/settings";
17import DidHoverCard from "./hover-card/did";
18import RecordHoverCard from "./hover-card/record";
19import { pds } from "./navbar";
20import { addNotification, removeNotification } from "./notification";
21import VideoPlayer from "./video-player";
22
23interface JSONContext {
24 repo: string;
25 truncate?: boolean;
26 parentIsBlob?: boolean;
27 newTab?: boolean;
28 hideBlobs?: boolean;
29}
30
31const JSONCtx = createContext<JSONContext>();
32const useJSONCtx = () => useContext(JSONCtx)!;
33
34interface AtBlob {
35 $type: string;
36 ref: { $link: string };
37 mimeType: string;
38 size: number;
39}
40
41const isURL =
42 URL.canParse ??
43 ((url, base) => {
44 try {
45 new URL(url, base);
46 return true;
47 } catch {
48 return false;
49 }
50 });
51
52const JSONString = (props: { data: string; isType?: boolean; isLink?: boolean }) => {
53 const ctx = useJSONCtx();
54 const navigate = useNavigate();
55 const params = useParams();
56
57 const handleClick = async (lex: string) => {
58 try {
59 const [nsid, anchor] = lex.split("#");
60 const authority = await resolveLexiconAuthority(nsid as Nsid);
61
62 const hash = anchor ? `#schema:${anchor}` : "#schema";
63 if (ctx.newTab)
64 window.open(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`, "_blank");
65 else navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`);
66 } catch (err) {
67 console.error("Failed to resolve lexicon authority:", err);
68 const id = addNotification({
69 message: "Could not resolve schema",
70 type: "error",
71 });
72 setTimeout(() => removeNotification(id), 5000);
73 }
74 };
75
76 const MAX_LENGTH = 200;
77 const isTruncated = () => ctx.truncate && props.data.length > MAX_LENGTH;
78 const displayData = () => (isTruncated() ? props.data.slice(0, MAX_LENGTH) : props.data);
79 const remainingChars = () => props.data.length - MAX_LENGTH;
80
81 return (
82 <span>
83 "
84 <For each={displayData().split(/(\s)/)}>
85 {(part) => (
86 <>
87 {isResourceUri(part) ?
88 <RecordHoverCard uri={part} newTab={ctx.newTab} />
89 : isDid(part) ?
90 <DidHoverCard did={part} newTab={ctx.newTab} />
91 : isNsid(part.split("#")[0]) && props.isType ?
92 <button
93 type="button"
94 onClick={() => handleClick(part)}
95 class="cursor-pointer text-blue-500 hover:underline active:underline dark:text-blue-400"
96 >
97 {part}
98 </button>
99 : isCid(part) && props.isLink && ctx.parentIsBlob && params.repo ?
100 <A
101 class="text-blue-500 hover:underline active:underline dark:text-blue-400"
102 rel="noopener"
103 target="_blank"
104 href={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${params.repo}&cid=${part}`}
105 >
106 {part}
107 </A>
108 : (
109 isURL(part) &&
110 ["http:", "https:", "web+at:"].includes(new URL(part).protocol) &&
111 part.split("\n").length === 1
112 ) ?
113 <a
114 class="underline hover:text-blue-500 dark:hover:text-blue-400"
115 href={part}
116 target="_blank"
117 rel="noopener"
118 >
119 {part}
120 </a>
121 : part}
122 </>
123 )}
124 </For>
125 <Show when={isTruncated()}>
126 <span>…</span>
127 </Show>
128 "
129 <Show when={isTruncated()}>
130 <span class="ml-1 text-neutral-500 dark:text-neutral-400">
131 (+{remainingChars().toLocaleString()})
132 </span>
133 </Show>
134 </span>
135 );
136};
137
138const JSONNumber = ({ data, isSize }: { data: number; isSize?: boolean }) => {
139 return (
140 <span class="flex gap-1">
141 {data}
142 <Show when={isSize}>
143 <span class="text-neutral-500 dark:text-neutral-400">({formatFileSize(data)})</span>
144 </Show>
145 </span>
146 );
147};
148
149const JSONBoolean = ({ data }: { data: boolean }) => {
150 return <span>{data ? "true" : "false"}</span>;
151};
152
153const JSONNull = () => {
154 return <span>null</span>;
155};
156
157const CollapsibleItem = (props: {
158 label: string | number;
159 value: JSONType;
160 maxWidth?: string;
161 isType?: boolean;
162 isLink?: boolean;
163 isSize?: boolean;
164 parentIsBlob?: boolean;
165}) => {
166 const ctx = useJSONCtx();
167 const [show, setShow] = createSignal(true);
168 const isBlobContext = props.parentIsBlob ?? ctx.parentIsBlob;
169
170 return (
171 <span
172 classList={{
173 "group/indent flex gap-x-1 w-full": true,
174 "flex-col": props.value === Object(props.value),
175 }}
176 >
177 <button
178 class="group/clip relative flex size-fit shrink-0 items-center wrap-anywhere text-neutral-500 hover:text-neutral-700 active:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 dark:active:text-neutral-300"
179 classList={{
180 "max-w-[40%] sm:max-w-[50%]": props.maxWidth !== undefined,
181 }}
182 onclick={() => setShow(!show())}
183 >
184 <span
185 classList={{
186 "dark:bg-dark-500 absolute w-4 flex items-center -left-4 bg-neutral-100 text-sm": true,
187 "hidden group-hover/clip:flex": show(),
188 }}
189 >
190 {show() ?
191 <span class="iconify lucide--chevron-down"></span>
192 : <span class="iconify lucide--chevron-right"></span>}
193 </span>
194 {props.label}:
195 </button>
196 <span
197 classList={{
198 "self-center": props.value !== Object(props.value),
199 "pl-[calc(2ch-0.5px)] border-l-[0.5px] border-neutral-500/50 dark:border-neutral-400/50 has-hover:group-hover/indent:border-neutral-700 transition-colors dark:has-hover:group-hover/indent:border-neutral-300":
200 props.value === Object(props.value),
201 "invisible h-0 overflow-hidden": !show(),
202 }}
203 >
204 <JSONCtx.Provider value={{ ...ctx, parentIsBlob: isBlobContext }}>
205 <JSONValueInner
206 data={props.value}
207 isType={props.isType}
208 isLink={props.isLink}
209 isSize={props.isSize}
210 />
211 </JSONCtx.Provider>
212 </span>
213 </span>
214 );
215};
216
217const JSONObject = (props: { data: { [x: string]: JSONType } }) => {
218 const ctx = useJSONCtx();
219 const params = useParams();
220 const [hide, setHide] = createSignal(
221 localStorage.hideMedia === "true" || params.rkey === undefined,
222 );
223 const [mediaLoaded, setMediaLoaded] = createSignal(false);
224
225 createEffect(() => {
226 if (hideMedia()) setHide(hideMedia());
227 });
228
229 createEffect(
230 on(
231 hide,
232 (value) => {
233 if (value === false) setMediaLoaded(false);
234 },
235 { defer: true },
236 ),
237 );
238
239 const isBlob = props.data.$type === "blob";
240 const isBlobContext = isBlob || ctx.parentIsBlob;
241
242 const rawObj = (
243 <For each={Object.entries(props.data)}>
244 {([key, value]) => (
245 <CollapsibleItem
246 label={key}
247 value={value}
248 maxWidth="set"
249 isType={key === "$type"}
250 isLink={key === "$link"}
251 isSize={key === "size" && isBlob}
252 parentIsBlob={isBlobContext}
253 />
254 )}
255 </For>
256 );
257
258 const blob: AtBlob = props.data as any;
259 const canShowMedia = () =>
260 pds() &&
261 !ctx.hideBlobs &&
262 (blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4");
263
264 const MediaDisplay = () => {
265 const [imageUrl] = createResource(
266 () => (blob.mimeType.startsWith("image/") ? blob.ref.$link : null),
267 async (cid) => {
268 const url = `https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`;
269
270 await new Promise<void>((resolve) => {
271 const img = new Image();
272 img.src = url;
273 img.onload = () => resolve();
274 img.onerror = () => resolve();
275 });
276
277 return url;
278 },
279 );
280
281 return (
282 <div>
283 <span class="group/media relative flex w-fit">
284 <Show when={!hide()}>
285 <Show when={blob.mimeType.startsWith("image/")}>
286 <Show
287 when={!imageUrl.loading && imageUrl()}
288 fallback={
289 <div class="flex h-48 w-48 items-center justify-center rounded bg-neutral-200 dark:bg-neutral-800">
290 <span class="iconify lucide--loader-circle animate-spin text-xl text-neutral-400 dark:text-neutral-500"></span>
291 </div>
292 }
293 >
294 <img
295 class="h-auto max-h-48 max-w-64 object-contain"
296 src={imageUrl()}
297 onLoad={() => setMediaLoaded(true)}
298 />
299 </Show>
300 </Show>
301 <Show when={blob.mimeType === "video/mp4"}>
302 <ErrorBoundary fallback={() => <span>Failed to load video</span>}>
303 <VideoPlayer
304 did={ctx.repo}
305 cid={blob.ref.$link}
306 onLoad={() => setMediaLoaded(true)}
307 />
308 </ErrorBoundary>
309 </Show>
310 <Show when={mediaLoaded()}>
311 <button
312 onclick={() => setHide(true)}
313 class="absolute top-1 right-1 flex items-center rounded-lg bg-neutral-700/70 p-1.5 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/media:opacity-100 hover:bg-neutral-700 active:bg-neutral-800 dark:bg-neutral-100/70 dark:text-neutral-900 dark:hover:bg-neutral-100 dark:active:bg-neutral-200"
314 >
315 <span class="iconify lucide--eye-off text-base"></span>
316 </button>
317 </Show>
318 </Show>
319 <Show when={hide()}>
320 <button
321 onclick={() => setHide(false)}
322 class="flex items-center gap-1 rounded-md bg-neutral-200 px-2 py-1.5 text-sm transition-colors hover:bg-neutral-300 active:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:active:bg-neutral-500"
323 >
324 <span class="iconify lucide--image"></span>
325 <span class="font-sans">Show media</span>
326 </button>
327 </Show>
328 </span>
329 </div>
330 );
331 };
332
333 if (blob.$type === "blob") {
334 return (
335 <>
336 <Show when={canShowMedia()}>
337 <MediaDisplay />
338 </Show>
339 {rawObj}
340 </>
341 );
342 }
343
344 return rawObj;
345};
346
347const JSONArray = (props: { data: JSONType[] }) => {
348 return (
349 <For each={props.data}>
350 {(value, index) => <CollapsibleItem label={`#${index()}`} value={value} />}
351 </For>
352 );
353};
354
355const JSONValueInner = (props: {
356 data: JSONType;
357 isType?: boolean;
358 isLink?: boolean;
359 isSize?: boolean;
360}) => {
361 const data = props.data;
362 if (typeof data === "string")
363 return <JSONString data={data} isType={props.isType} isLink={props.isLink} />;
364 if (typeof data === "number") return <JSONNumber data={data} isSize={props.isSize} />;
365 if (typeof data === "boolean") return <JSONBoolean data={data} />;
366 if (data === null) return <JSONNull />;
367 if (Array.isArray(data)) return <JSONArray data={data} />;
368 return <JSONObject data={data} />;
369};
370
371export const JSONValue = (props: {
372 data: JSONType;
373 repo: string;
374 truncate?: boolean;
375 newTab?: boolean;
376 hideBlobs?: boolean;
377}) => {
378 return (
379 <JSONCtx.Provider
380 value={{
381 repo: props.repo,
382 truncate: props.truncate,
383 newTab: props.newTab,
384 hideBlobs: props.hideBlobs,
385 }}
386 >
387 <JSONValueInner data={props.data} />
388 </JSONCtx.Provider>
389 );
390};
391
392export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];