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