1import { isCid, 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 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 setNotif({
47 show: true,
48 icon: "lucide--circle-alert",
49 text: "Could not resolve schema",
50 });
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 ?
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" 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 const isBlob = props.data.$type === "blob";
130 const isBlobContext = isBlob || props.parentIsBlob;
131
132 const Obj = ({ key, value }: { key: string; value: JSONType }) => {
133 const [show, setShow] = createSignal(true);
134
135 return (
136 <span
137 classList={{
138 "group/indent flex gap-x-1 w-full": true,
139 "flex-col": value === Object(value),
140 }}
141 >
142 <button
143 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"
144 onclick={() => setShow(!show())}
145 >
146 <span
147 classList={{
148 "dark:bg-dark-500 absolute w-5 flex items-center -left-5 bg-neutral-100 text-sm": true,
149 "hidden group-hover/clip:flex": show(),
150 }}
151 >
152 {show() ?
153 <span class="iconify lucide--chevron-down"></span>
154 : <span class="iconify lucide--chevron-right"></span>}
155 </span>
156 {key}:
157 </button>
158 <span
159 classList={{
160 "self-center": value !== Object(value),
161 "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":
162 value === Object(value),
163 "invisible h-0": !show(),
164 }}
165 >
166 <JSONValue
167 data={value}
168 repo={props.repo}
169 isType={key === "$type"}
170 isLink={key === "$link"}
171 parentIsBlob={isBlobContext}
172 />
173 </span>
174 </span>
175 );
176 };
177
178 const rawObj = (
179 <For each={Object.entries(props.data)}>{([key, value]) => <Obj key={key} value={value} />}</For>
180 );
181
182 const blob: AtBlob = props.data as any;
183
184 if (blob.$type === "blob") {
185 return (
186 <>
187 <Show when={pds() && params.rkey}>
188 <Show when={blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4"}>
189 <span class="group/media relative flex w-fit">
190 <Show when={!hide()}>
191 <Show when={blob.mimeType.startsWith("image/")}>
192 <img
193 class="h-auto max-h-64 max-w-[16rem] object-contain"
194 src={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${props.repo}&cid=${blob.ref.$link}`}
195 onLoad={() => setMediaLoaded(true)}
196 />
197 </Show>
198 <Show when={blob.mimeType === "video/mp4"}>
199 <ErrorBoundary fallback={() => <span>Failed to load video</span>}>
200 <VideoPlayer did={props.repo} cid={blob.ref.$link} onLoad={() => setMediaLoaded(true)} />
201 </ErrorBoundary>
202 </Show>
203 <Show when={mediaLoaded()}>
204 <button
205 onclick={() => setHide(true)}
206 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"
207 >
208 <span class="iconify lucide--eye-off text-base"></span>
209 </button>
210 </Show>
211 </Show>
212 <Show when={hide()}>
213 <button
214 onclick={() => setHide(false)}
215 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"
216 >
217 <span class="iconify lucide--eye text-base"></span>
218 </button>
219 </Show>
220 </span>
221 </Show>
222 </Show>
223 {rawObj}
224 </>
225 );
226 }
227
228 return rawObj;
229};
230
231const JSONArray = (props: { data: JSONType[]; repo: string; parentIsBlob?: boolean }) => {
232 return (
233 <For each={props.data}>
234 {(value, index) => (
235 <span
236 classList={{
237 "flex before:content-['-']": true,
238 "mb-2": value === Object(value) && index() !== props.data.length - 1,
239 }}
240 >
241 <span class="ml-[1ch] w-full">
242 <JSONValue data={value} repo={props.repo} parentIsBlob={props.parentIsBlob} />
243 </span>
244 </span>
245 )}
246 </For>
247 );
248};
249
250export const JSONValue = (props: {
251 data: JSONType;
252 repo: string;
253 isType?: boolean;
254 isLink?: boolean;
255 parentIsBlob?: boolean;
256}) => {
257 const data = props.data;
258 if (typeof data === "string")
259 return (
260 <JSONString
261 data={data}
262 isType={props.isType}
263 isLink={props.isLink}
264 parentIsBlob={props.parentIsBlob}
265 />
266 );
267 if (typeof data === "number") return <JSONNumber data={data} />;
268 if (typeof data === "boolean") return <JSONBoolean data={data} />;
269 if (data === null) return <JSONNull />;
270 if (Array.isArray(data))
271 return <JSONArray data={data} repo={props.repo} parentIsBlob={props.parentIsBlob} />;
272 return <JSONObject data={data} repo={props.repo} parentIsBlob={props.parentIsBlob} />;
273};
274
275export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];