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