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