import { isCid, isDid, isNsid, isResourceUri } from "@atcute/lexicons/syntax"; import { A, useLocation, useParams } from "@solidjs/router"; import { createContext, createEffect, createResource, createSignal, ErrorBoundary, For, onCleanup, Show, useContext, } from "solid-js"; import { formatFileSize } from "../utils/format"; import { hideMedia } from "../views/settings"; import DidHoverCard from "./hover-card/did"; import LexiconHoverCard from "./hover-card/lexicon"; import RecordHoverCard from "./hover-card/record"; import VideoPlayer from "./video-player"; import { ZoomableImage } from "./zoomable-image"; interface JSONContext { repo: string; pds?: string; truncate?: boolean; parentIsBlob?: boolean; newTab?: boolean; hideBlobs?: boolean; keyLinks?: boolean; path?: string; preview?: boolean; depth?: number; fetchBlob?: (cid: string) => Promise; blobHref?: (cid: string) => string; } const JSONCtx = createContext(); const useJSONCtx = () => useContext(JSONCtx)!; const PREVIEW_EXPANDED_DEPTH = 2; const PREVIEW_EXPANDED_CHILD_LIMIT = 8; interface AtBlob { $type: string; ref: { $link: string }; mimeType: string; size: number; } const isURL = URL.canParse ?? ((url, base) => { try { new URL(url, base); return true; } catch { return false; } }); const JSONString = (props: { data: string; isType?: boolean; isLink?: boolean }) => { const ctx = useJSONCtx(); const params = useParams(); const location = useLocation(); const MAX_LENGTH = 200; const isTruncated = () => ctx.truncate && props.data.length > MAX_LENGTH; const displayData = () => (isTruncated() ? props.data.slice(0, MAX_LENGTH) : props.data); const remainingChars = () => props.data.length - MAX_LENGTH; return ( " {(part) => ( <> {isResourceUri(part) ? ( ) : isDid(part) ? ( ) : isNsid(part.split("#")[0]) && props.isType ? ( ) : isCid(part) && props.isLink && ctx.parentIsBlob && (ctx.blobHref || params.repo) ? ( {part} ) : isURL(part) && ["http:", "https:", "web+at:"].includes(new URL(part).protocol) && part.split("\n").length === 1 ? ( {part} ) : ( part )} )} … " (+{remainingChars().toLocaleString()}) ); }; const JSONNumber = ({ data, isSize }: { data: number; isSize?: boolean }) => { return ( {data} ({formatFileSize(data)}) ); }; const CollapsibleItem = (props: { label: string | number; value: JSONType; maxWidth?: string; isType?: boolean; isLink?: boolean; isSize?: boolean; isIndex?: boolean; parentIsBlob?: boolean; }) => { const ctx = useJSONCtx(); const location = useLocation(); const isObject = () => props.value === Object(props.value); const isEmpty = () => Array.isArray(props.value) ? (props.value as JSONType[]).length === 0 : Object.keys(props.value as object).length === 0; const childCount = () => Array.isArray(props.value) ? (props.value as JSONType[]).length : Object.keys(props.value as object).length; const valueDepth = () => (ctx.depth ?? 0) + 1; const shouldCollapsePreview = () => ctx.preview && isObject() && !isEmpty() && (valueDepth() > PREVIEW_EXPANDED_DEPTH || childCount() > PREVIEW_EXPANDED_CHILD_LIMIT); const [show, setShow] = createSignal(!shouldCollapsePreview()); const isBlobContext = props.parentIsBlob ?? ctx.parentIsBlob; const labelStr = () => { const l = String(props.label); return l.startsWith("#") ? l.slice(1) : l; }; const fullPath = () => (ctx.path ? `${ctx.path}.${labelStr()}` : labelStr()); const isHighlighted = () => location.hash === `#record:${fullPath()}`; createEffect(() => { if (isHighlighted()) { requestAnimationFrame(() => { document .getElementById(`key-${fullPath()}`) ?.scrollIntoView({ behavior: "instant", block: "center" }); }); } }); const summary = () => { if (Array.isArray(props.value)) { const len = (props.value as JSONType[]).length; return `[ ${len} ${len === 1 ? "item" : "items"} ]`; } const len = Object.keys(props.value as object).length; return `{ ${len} ${len === 1 ? "key" : "keys"} }`; }; return ( {props.label} : } > {props.label} : setShow(!show())} > ); }; const JSONObject = (props: { data: { [x: string]: JSONType } }) => { const ctx = useJSONCtx(); const isBlob = props.data.$type === "blob"; const isBlobContext = isBlob || ctx.parentIsBlob; const rawObj = ( {([key, value]) => ( )} ); const blob: AtBlob = props.data as any; const canShowMedia = () => (ctx.pds || ctx.fetchBlob) && !ctx.hideBlobs && (blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4" || blob.mimeType.startsWith("audio/")); const MediaDisplay = () => { const [overrideShow, setOverrideShow] = createSignal(false); const [mediaError, setMediaError] = createSignal(false); const hidden = () => hideMedia() && !overrideShow(); let objectUrl: string | undefined; const [mediaUrl] = createResource( () => blob.ref.$link, async (cid) => { setMediaError(false); if (ctx.fetchBlob) { try { const data = await ctx.fetchBlob(cid); objectUrl = URL.createObjectURL(data); return objectUrl; } catch { setMediaError(true); return; } } const url = `${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`; if (blob.mimeType.startsWith("image/")) { await new Promise((resolve) => { const img = new Image(); img.src = url; img.onload = () => resolve(); img.onerror = () => resolve(); }); } return url; }, ); onCleanup(() => { if (objectUrl) URL.revokeObjectURL(objectUrl); }); const LoadingMedia = () => (
); return (
Failed to load media } > }> Failed to load video}> } > }> }>
); }; if (Object.keys(props.data).length === 0) return {"{ }"}; if (blob.$type === "blob") { return ( <> {rawObj} ); } return rawObj; }; const JSONArray = (props: { data: JSONType[] }) => { if (props.data.length === 0) return [ ]; return ( {(value, index) => } ); }; const JSONValueInner = (props: { data: JSONType; isType?: boolean; isLink?: boolean; isSize?: boolean; }) => { const data = props.data; if (typeof data === "string") return ; if (typeof data === "number") return ; if (typeof data === "boolean") return {String(data)}; if (data === null) return null; if (Array.isArray(data)) return ; return ; }; export const JSONValue = (props: { data: JSONType; repo: string; pds?: string; truncate?: boolean; newTab?: boolean; hideBlobs?: boolean; keyLinks?: boolean; preview?: boolean; fetchBlob?: (cid: string) => Promise; blobHref?: (cid: string) => string; }) => { return ( ); }; export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];