atmosphere explorer pds.ls
tool typescript atproto
439
fork

Configure Feed

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

at v1.3.0 460 lines 18 kB view raw
1import { Client } from "@atcute/client"; 2import { Did } from "@atcute/lexicons"; 3import { isNsid, isRecordKey } from "@atcute/lexicons/syntax"; 4import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 5import { useNavigate, useParams } from "@solidjs/router"; 6import { 7 createEffect, 8 createSignal, 9 For, 10 lazy, 11 onCleanup, 12 onMount, 13 Show, 14 Suspense, 15} from "solid-js"; 16import { hasUserScope } from "../../auth/scope-utils"; 17import { agent, sessions } from "../../auth/state"; 18import { Button } from "../button.jsx"; 19import { Modal } from "../modal.jsx"; 20import { addNotification, removeNotification } from "../notification.jsx"; 21import { TextInput } from "../text-input.jsx"; 22import Tooltip from "../tooltip.jsx"; 23import { ConfirmSubmit } from "./confirm-submit"; 24import { FileUpload } from "./file-upload"; 25import { HandleInput } from "./handle-input"; 26import { MenuItem } from "./menu-item"; 27import { editorInstance, placeholder, setPlaceholder } from "./state"; 28 29const Editor = lazy(() => import("../editor.jsx").then((m) => ({ default: m.Editor }))); 30 31export { editorInstance, placeholder, setPlaceholder }; 32 33export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 34 const navigate = useNavigate(); 35 const params = useParams(); 36 const [openDialog, setOpenDialog] = createSignal(false); 37 const [notice, setNotice] = createSignal(""); 38 const [openUpload, setOpenUpload] = createSignal(false); 39 const [openInsertMenu, setOpenInsertMenu] = createSignal(false); 40 const [openHandleDialog, setOpenHandleDialog] = createSignal(false); 41 const [openConfirmDialog, setOpenConfirmDialog] = createSignal(false); 42 const [isMaximized, setIsMaximized] = createSignal(false); 43 const [isMinimized, setIsMinimized] = createSignal(false); 44 const [collectionError, setCollectionError] = createSignal(""); 45 const [rkeyError, setRkeyError] = createSignal(""); 46 let blobInput!: HTMLInputElement; 47 let formRef!: HTMLFormElement; 48 let insertMenuRef!: HTMLDivElement; 49 50 createEffect(() => { 51 if (openInsertMenu()) { 52 const handleClickOutside = (e: MouseEvent) => { 53 if (insertMenuRef && !insertMenuRef.contains(e.target as Node)) { 54 setOpenInsertMenu(false); 55 } 56 }; 57 document.addEventListener("mousedown", handleClickOutside); 58 onCleanup(() => document.removeEventListener("mousedown", handleClickOutside)); 59 } 60 }); 61 62 onMount(() => { 63 const keyEvent = (ev: KeyboardEvent) => { 64 if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return; 65 if ((ev.target as HTMLElement).closest("[data-modal]")) return; 66 67 const key = props.create ? "n" : "e"; 68 if (ev.key === key) { 69 ev.preventDefault(); 70 71 if (openDialog() && isMinimized()) { 72 setIsMinimized(false); 73 } else if (!openDialog() && !document.querySelector("[data-modal]")) { 74 setOpenDialog(true); 75 } 76 } 77 }; 78 79 window.addEventListener("keydown", keyEvent); 80 onCleanup(() => window.removeEventListener("keydown", keyEvent)); 81 }); 82 83 const defaultPlaceholder = () => { 84 return { 85 $type: "app.bsky.feed.post", 86 text: "This post was sent from PDSls", 87 embed: { 88 $type: "app.bsky.embed.external", 89 external: { 90 uri: "https://pdsls.dev", 91 title: "PDSls", 92 description: "Browse the public data on atproto", 93 }, 94 }, 95 langs: ["en"], 96 createdAt: new Date().toISOString(), 97 }; 98 }; 99 100 createEffect(() => { 101 if (openDialog()) { 102 setCollectionError(""); 103 setRkeyError(""); 104 } 105 }); 106 107 const createRecord = async (validate: boolean | undefined) => { 108 const formData = new FormData(formRef); 109 const repo = formData.get("repo")?.toString(); 110 if (!repo) return; 111 const rpc = new Client({ handler: new OAuthUserAgent(await getSession(repo as Did)) }); 112 const collection = formData.get("collection"); 113 const rkey = formData.get("rkey"); 114 let record: any; 115 try { 116 record = JSON.parse(editorInstance.view.state.doc.toString()); 117 } catch (e: any) { 118 setNotice(e.message); 119 return; 120 } 121 const res = await rpc.post("com.atproto.repo.createRecord", { 122 input: { 123 repo: repo as Did, 124 collection: collection ? collection.toString() : record.$type, 125 rkey: rkey?.toString().length ? rkey?.toString() : undefined, 126 record: record, 127 validate: validate, 128 }, 129 }); 130 if (!res.ok) { 131 setNotice(`${res.data.error}: ${res.data.message}`); 132 return; 133 } 134 setOpenConfirmDialog(false); 135 setOpenDialog(false); 136 const id = addNotification({ 137 message: "Record created", 138 type: "success", 139 }); 140 setTimeout(() => removeNotification(id), 3000); 141 navigate(`/${res.data.uri}`); 142 }; 143 144 const editRecord = async (validate: boolean | undefined, recreate: boolean) => { 145 const record = editorInstance.view.state.doc.toString(); 146 if (!record) return; 147 const rpc = new Client({ handler: agent()! }); 148 try { 149 const editedRecord = JSON.parse(record); 150 if (recreate) { 151 const res = await rpc.post("com.atproto.repo.applyWrites", { 152 input: { 153 repo: agent()!.sub, 154 validate: validate, 155 writes: [ 156 { 157 collection: params.collection as `${string}.${string}.${string}`, 158 rkey: params.rkey!, 159 $type: "com.atproto.repo.applyWrites#delete", 160 }, 161 { 162 collection: params.collection as `${string}.${string}.${string}`, 163 rkey: params.rkey, 164 $type: "com.atproto.repo.applyWrites#create", 165 value: editedRecord, 166 }, 167 ], 168 }, 169 }); 170 if (!res.ok) { 171 setNotice(`${res.data.error}: ${res.data.message}`); 172 return; 173 } 174 } else { 175 const res = await rpc.post("com.atproto.repo.applyWrites", { 176 input: { 177 repo: agent()!.sub, 178 validate: validate, 179 writes: [ 180 { 181 collection: params.collection as `${string}.${string}.${string}`, 182 rkey: params.rkey!, 183 $type: "com.atproto.repo.applyWrites#update", 184 value: editedRecord, 185 }, 186 ], 187 }, 188 }); 189 if (!res.ok) { 190 setNotice(`${res.data.error}: ${res.data.message}`); 191 return; 192 } 193 } 194 setOpenConfirmDialog(false); 195 setOpenDialog(false); 196 const id = addNotification({ 197 message: "Record edited", 198 type: "success", 199 }); 200 setTimeout(() => removeNotification(id), 3000); 201 props.refetch(); 202 } catch (err: any) { 203 setNotice(err.message); 204 } 205 }; 206 207 const insertTimestamp = () => { 208 const timestamp = new Date().toISOString(); 209 editorInstance.view.dispatch({ 210 changes: { 211 from: editorInstance.view.state.selection.main.head, 212 insert: `"${timestamp}"`, 213 }, 214 }); 215 setOpenInsertMenu(false); 216 }; 217 218 const insertDidFromHandle = () => { 219 setOpenInsertMenu(false); 220 setOpenHandleDialog(true); 221 }; 222 223 return ( 224 <> 225 <Modal 226 open={openDialog()} 227 onClose={() => setOpenDialog(false)} 228 closeOnClick={false} 229 nonBlocking={isMinimized()} 230 > 231 <div 232 style="transform: translateX(-50%) translateZ(0);" 233 classList={{ 234 "dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto absolute top-18 left-1/2 flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-all duration-200 dark:border-neutral-700 starting:opacity-0": true, 235 "w-[calc(100%-1rem)] max-w-3xl h-[65vh]": !isMaximized(), 236 "w-[calc(100%-1rem)] max-w-7xl h-[85vh]": isMaximized(), 237 hidden: isMinimized(), 238 }} 239 > 240 <div class="mb-2 flex w-full justify-between text-base"> 241 <div class="flex items-center gap-2"> 242 <span class="font-semibold select-none"> 243 {props.create ? "Creating" : "Editing"} record 244 </span> 245 </div> 246 <div class="flex items-center gap-1"> 247 <button 248 type="button" 249 onclick={() => setIsMinimized(true)} 250 class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 251 > 252 <span class="iconify lucide--minus"></span> 253 </button> 254 <button 255 type="button" 256 onclick={() => setIsMaximized(!isMaximized())} 257 class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 258 > 259 <span 260 class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`} 261 ></span> 262 </button> 263 <button 264 id="close" 265 onclick={() => setOpenDialog(false)} 266 class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 267 > 268 <span class="iconify lucide--x"></span> 269 </button> 270 </div> 271 </div> 272 <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2"> 273 <Show when={props.create}> 274 <div class="flex flex-wrap items-center gap-1 text-sm"> 275 <span>at://</span> 276 <select 277 class="dark:bg-dark-100 max-w-40 truncate rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 278 name="repo" 279 id="repo" 280 > 281 <For each={Object.keys(sessions)}> 282 {(session) => ( 283 <option value={session} selected={session === agent()?.sub}> 284 {sessions[session].handle ?? session} 285 </option> 286 )} 287 </For> 288 </select> 289 <span>/</span> 290 <TextInput 291 id="collection" 292 name="collection" 293 placeholder="Collection (default: $type)" 294 class={`w-40 placeholder:text-xs lg:w-52 ${collectionError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 295 onInput={(e) => { 296 const value = e.currentTarget.value; 297 if (!value || isNsid(value)) setCollectionError(""); 298 else 299 setCollectionError( 300 "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)", 301 ); 302 }} 303 /> 304 <span>/</span> 305 <TextInput 306 id="rkey" 307 name="rkey" 308 placeholder="Record key (default: TID)" 309 class={`w-40 placeholder:text-xs lg:w-52 ${rkeyError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 310 onInput={(e) => { 311 const value = e.currentTarget.value; 312 if (!value || isRecordKey(value)) setRkeyError(""); 313 else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -"); 314 }} 315 /> 316 </div> 317 <Show when={collectionError() || rkeyError()}> 318 <div class="text-xs text-red-500 dark:text-red-400"> 319 <div>{collectionError()}</div> 320 <div>{rkeyError()}</div> 321 </div> 322 </Show> 323 </Show> 324 <div class="min-h-0 flex-1"> 325 <Suspense 326 fallback={ 327 <div class="flex h-full items-center justify-center"> 328 <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 329 </div> 330 } 331 > 332 <Editor 333 content={JSON.stringify( 334 !props.create ? props.record 335 : params.rkey ? placeholder() 336 : defaultPlaceholder(), 337 null, 338 2, 339 )} 340 /> 341 </Suspense> 342 </div> 343 <div class="flex flex-col gap-2"> 344 <Show when={notice()}> 345 <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 346 </Show> 347 <div class="flex justify-between gap-2"> 348 <div class="relative" ref={insertMenuRef}> 349 <button 350 type="button" 351 class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 flex w-fit rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 text-base shadow-xs hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 352 onClick={() => setOpenInsertMenu(!openInsertMenu())} 353 > 354 <span class="iconify lucide--plus select-none"></span> 355 </button> 356 <Show when={openInsertMenu()}> 357 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700"> 358 <MenuItem 359 icon="lucide--id-card" 360 label="Insert DID" 361 onClick={insertDidFromHandle} 362 /> 363 <MenuItem 364 icon="lucide--clock" 365 label="Insert timestamp" 366 onClick={insertTimestamp} 367 /> 368 <Show when={hasUserScope("blob")}> 369 <MenuItem 370 icon="lucide--upload" 371 label="Upload blob" 372 onClick={() => { 373 setOpenInsertMenu(false); 374 blobInput.click(); 375 }} 376 /> 377 </Show> 378 </div> 379 </Show> 380 <input 381 type="file" 382 id="blob" 383 class="sr-only" 384 ref={blobInput} 385 onChange={(e) => { 386 if (e.target.files !== null) setOpenUpload(true); 387 }} 388 /> 389 </div> 390 <Modal 391 open={openUpload()} 392 onClose={() => setOpenUpload(false)} 393 closeOnClick={false} 394 > 395 <FileUpload 396 file={blobInput.files![0]} 397 blobInput={blobInput} 398 onClose={() => setOpenUpload(false)} 399 /> 400 </Modal> 401 <Modal 402 open={openHandleDialog()} 403 onClose={() => setOpenHandleDialog(false)} 404 closeOnClick={false} 405 > 406 <HandleInput onClose={() => setOpenHandleDialog(false)} /> 407 </Modal> 408 <Modal 409 open={openConfirmDialog()} 410 onClose={() => setOpenConfirmDialog(false)} 411 closeOnClick={false} 412 > 413 <ConfirmSubmit 414 isCreate={props.create} 415 onConfirm={(validate, recreate) => { 416 if (props.create) { 417 createRecord(validate); 418 } else { 419 editRecord(validate, recreate); 420 } 421 }} 422 onClose={() => setOpenConfirmDialog(false)} 423 /> 424 </Modal> 425 <div class="flex items-center justify-end gap-2"> 426 <Button onClick={() => setOpenConfirmDialog(true)}> 427 {props.create ? "Create..." : "Edit..."} 428 </Button> 429 </div> 430 </div> 431 </div> 432 </form> 433 </div> 434 </Modal> 435 <Show when={isMinimized() && openDialog()}> 436 <button 437 class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 fixed right-4 bottom-4 z-30 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-2 shadow-md hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 438 onclick={() => setIsMinimized(false)} 439 > 440 <span class="iconify lucide--square-pen text-lg"></span> 441 <span class="text-sm font-medium">{props.create ? "Creating" : "Editing"} record</span> 442 </button> 443 </Show> 444 <Tooltip text={props.create ? "Create record (n)" : "Edit record (e)"}> 445 <button 446 class={`flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}`} 447 onclick={() => { 448 setNotice(""); 449 setOpenDialog(true); 450 setIsMinimized(false); 451 }} 452 > 453 <div 454 class={props.create ? "iconify lucide--square-pen text-lg" : "iconify lucide--pencil"} 455 /> 456 </button> 457 </Tooltip> 458 </> 459 ); 460};