1import { isDid, isNsid, Nsid } from "@atcute/lexicons/syntax";
2import { A, useNavigate, useParams } from "@solidjs/router";
3import { createEffect, createSignal, ErrorBoundary, For, Show } from "solid-js";
4import { resolveLexiconAuthority } from "../utils/api";
5import { ATURI_RE } from "../utils/types/at-uri";
6import { hideMedia } from "../views/settings";
7import { pds } from "./navbar";
8import Tooltip from "./tooltip";
9import VideoPlayer from "./video-player";
10
11interface AtBlob {
12 $type: string;
13 ref: { $link: string };
14 mimeType: string;
15}
16
17const JSONString = ({ data, isType }: { data: string; isType?: boolean }) => {
18 const navigate = useNavigate();
19
20 const isURL =
21 URL.canParse ??
22 ((url, base) => {
23 try {
24 new URL(url, base);
25 return true;
26 } catch {
27 return false;
28 }
29 });
30
31 const handleClick = async (lex: string) => {
32 try {
33 const [nsid, anchor] = lex.split("#");
34 const authority = await resolveLexiconAuthority(nsid as Nsid);
35
36 const hash = anchor ? `#schema:${anchor}` : "#schema";
37 navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`);
38 } catch (err) {
39 console.error("Failed to resolve lexicon authority:", err);
40 }
41 };
42
43 return (
44 <span>
45 "
46 <For each={data.split(/(\s)/)}>
47 {(part) => (
48 <>
49 {ATURI_RE.test(part) ?
50 <A class="text-blue-400 hover:underline active:underline" href={`/${part}`}>
51 {part}
52 </A>
53 : isDid(part) ?
54 <A class="text-blue-400 hover:underline active:underline" href={`/at://${part}`}>
55 {part}
56 </A>
57 : isNsid(part.split("#")[0]) && isType ?
58 <button
59 type="button"
60 onClick={() => handleClick(part)}
61 class="cursor-pointer text-blue-400 hover:underline active:underline"
62 >
63 {part}
64 </button>
65 : (
66 isURL(part) &&
67 ["http:", "https:", "web+at:"].includes(new URL(part).protocol) &&
68 part.split("\n").length === 1
69 ) ?
70 <a class="underline" href={part} target="_blank" rel="noopener">
71 {part}
72 </a>
73 : part}
74 </>
75 )}
76 </For>
77 "
78 </span>
79 );
80};
81
82const JSONNumber = ({ data }: { data: number }) => {
83 return <span>{data}</span>;
84};
85
86const JSONBoolean = ({ data }: { data: boolean }) => {
87 return <span>{data ? "true" : "false"}</span>;
88};
89
90const JSONNull = () => {
91 return <span>null</span>;
92};
93
94const JSONObject = ({ data, repo }: { data: { [x: string]: JSONType }; repo: string }) => {
95 const params = useParams();
96 const [hide, setHide] = createSignal(
97 localStorage.hideMedia === "true" || params.rkey === undefined,
98 );
99
100 createEffect(() => {
101 if (hideMedia()) setHide(hideMedia());
102 });
103
104 const Obj = ({ key, value }: { key: string; value: JSONType }) => {
105 const [show, setShow] = createSignal(true);
106
107 return (
108 <span
109 classList={{
110 "group/indent flex gap-x-1 w-full": true,
111 "flex-col": value === Object(value),
112 }}
113 >
114 <button
115 class="group/clip relative flex size-fit max-w-[40%] shrink-0 items-center wrap-anywhere text-neutral-500 hover:text-neutral-700 active:text-neutral-700 sm:max-w-[50%] dark:text-neutral-400 dark:hover:text-neutral-300 dark:active:text-neutral-300"
116 onclick={() => setShow(!show())}
117 >
118 <span
119 classList={{
120 "dark:bg-dark-500 absolute w-5 flex items-center -left-5 bg-neutral-100 text-sm": true,
121 "hidden group-hover/clip:flex": show(),
122 }}
123 >
124 {show() ?
125 <span class="iconify lucide--chevron-down"></span>
126 : <span class="iconify lucide--chevron-right"></span>}
127 </span>
128 {key}:
129 </button>
130 <span
131 classList={{
132 "self-center": value !== Object(value),
133 "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 dark:has-hover:group-hover/indent:border-neutral-300":
134 value === Object(value),
135 "invisible h-0": !show(),
136 }}
137 >
138 <JSONValue data={value} repo={repo} isType={key === "$type" ? true : undefined} />
139 </span>
140 </span>
141 );
142 };
143
144 const rawObj = (
145 <For each={Object.entries(data)}>{([key, value]) => <Obj key={key} value={value} />}</For>
146 );
147
148 const blob: AtBlob = data as any;
149
150 if (blob.$type === "blob") {
151 return (
152 <>
153 <Show when={pds() && params.rkey}>
154 <span class="flex gap-x-1">
155 <Show when={blob.mimeType.startsWith("image/") && !hide()}>
156 <img
157 class="h-auto max-h-64 max-w-[16rem] object-contain"
158 src={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${repo}&cid=${blob.ref.$link}`}
159 />
160 </Show>
161 <Show when={blob.mimeType === "video/mp4" && !hide()}>
162 <ErrorBoundary fallback={() => <span>Failed to load video</span>}>
163 <VideoPlayer did={repo} cid={blob.ref.$link} />
164 </ErrorBoundary>
165 </Show>
166 <span
167 classList={{
168 "flex items-center justify-between gap-1": true,
169 "flex-col": !hide(),
170 }}
171 >
172 <Show when={blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4"}>
173 <Tooltip text={hide() ? "Show" : "Hide"}>
174 <button
175 onclick={() => setHide(!hide())}
176 class={`${!hide() ? "-mt-1 -ml-0.5" : ""} flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`}
177 >
178 <span
179 class={`iconify text-base ${hide() ? "lucide--eye-off" : "lucide--eye"}`}
180 ></span>
181 </button>
182 </Tooltip>
183 </Show>
184 <Tooltip text="Blob on PDS">
185 <a
186 href={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${repo}&cid=${blob.ref.$link}`}
187 target="_blank"
188 class={`${!hide() ? "-mb-1 -ml-0.5" : ""} flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`}
189 >
190 <span class="iconify lucide--external-link text-base"></span>
191 </a>
192 </Tooltip>
193 </span>
194 </span>
195 </Show>
196 {rawObj}
197 </>
198 );
199 }
200
201 return rawObj;
202};
203
204const JSONArray = ({ data, repo }: { data: JSONType[]; repo: string }) => {
205 return (
206 <For each={data}>
207 {(value, index) => (
208 <span
209 classList={{
210 "flex before:content-['-']": true,
211 "mb-2": value === Object(value) && index() !== data.length - 1,
212 }}
213 >
214 <span class="ml-[1ch] w-full">
215 <JSONValue data={value} repo={repo} />
216 </span>
217 </span>
218 )}
219 </For>
220 );
221};
222
223export const JSONValue = (props: { data: JSONType; repo: string; isType?: boolean }) => {
224 const data = props.data;
225 if (typeof data === "string") return <JSONString data={data} isType={props.isType} />;
226 if (typeof data === "number") return <JSONNumber data={data} />;
227 if (typeof data === "boolean") return <JSONBoolean data={data} />;
228 if (data === null) return <JSONNull />;
229 if (Array.isArray(data)) return <JSONArray data={data} repo={props.repo} />;
230 return <JSONObject data={data} repo={props.repo} />;
231};
232
233export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];