atmosphere explorer pds.ls
tool typescript atproto
437
fork

Configure Feed

Select the types of activity you want to include in your feed.

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