BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

at main 653 lines 22 kB view raw
1import { ExplorerController } from "$/lib/api/explorer"; 2import { ProfileController } from "$/lib/api/profile"; 3import type { 4 ExplorerNavigation, 5 ExplorerTargetKind, 6 ExplorerViewLevel, 7 ExplorerViewState, 8} from "$/lib/api/types/explorer"; 9import { NAVIGATION_EVENT } from "$/lib/constants/events"; 10import { consumeQueuedExplorerTarget } from "$/lib/explorer-navigation"; 11import { listen } from "@tauri-apps/api/event"; 12import * as logger from "@tauri-apps/plugin-log"; 13import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 14import { produce } from "solid-js/store"; 15import { Motion, Presence } from "solid-motionone"; 16import { createExplorerState } from "./explorer-state"; 17import { ExplorerBreadcrumb } from "./ExplorerBreadcrumb"; 18import { ExplorerUrlBar } from "./ExplorerUrlBar"; 19import { CollectionView } from "./views/CollectionView"; 20import { PdsView } from "./views/PdsView"; 21import { RecordView } from "./views/RecordView"; 22import { RepoView } from "./views/RepoView"; 23 24function resolveParentInput(view: ExplorerViewState): string | null { 25 switch (view.level) { 26 case "record": { 27 if (view.resolved?.did && view.resolved?.collection) { 28 return `at://${view.resolved.did}/${view.resolved.collection}`; 29 } 30 break; 31 } 32 case "collection": { 33 if (view.resolved?.did) { 34 return `at://${view.resolved.did}`; 35 } 36 break; 37 } 38 case "repo": { 39 if (view.resolved?.pdsUrl) { 40 return view.resolved.pdsUrl; 41 } 42 break; 43 } 44 } 45 return null; 46} 47function extractCollections(repoData: Record<string, unknown>): Array<{ nsid: string }> { 48 const collections: Array<{ nsid: string }> = []; 49 const collectionsData = repoData.collections; 50 51 if (Array.isArray(collectionsData)) { 52 for (const collection of collectionsData) { 53 if (typeof collection === "string") { 54 collections.push({ nsid: collection }); 55 } 56 } 57 } 58 59 return collections.toSorted((left, right) => left.nsid.localeCompare(right.nsid)); 60} 61 62function hasCachedLexiconIcon(icons: Record<string, string | null>, collection: string) { 63 return Object.prototype.hasOwnProperty.call(icons, collection); 64} 65 66function parseExplorerTargetFromHash(hash: string) { 67 const queryIndex = hash.indexOf("?"); 68 if (queryIndex === -1 || queryIndex === hash.length - 1) { 69 return null; 70 } 71 72 const params = new URLSearchParams(hash.slice(queryIndex + 1)); 73 const value = params.get("target"); 74 if (!value) { 75 return null; 76 } 77 78 try { 79 return decodeURIComponent(value); 80 } catch { 81 return value; 82 } 83} 84 85export function ExplorerPanel() { 86 const explorer = createExplorerState(); 87 const [clearingIconCache, setClearingIconCache] = createSignal(false); 88 const [statusMessage, setStatusMessage] = createSignal<{ kind: "error" | "success"; text: string } | null>(null); 89 let resolveRequestId = 0; 90 91 const canGoBack = createMemo(() => explorer.canGoBack()); 92 const canGoForward = createMemo(() => explorer.canGoForward()); 93 const breadcrumb = createMemo(() => explorer.getBreadcrumb()); 94 const canExport = createMemo(() => !!explorer.state.current?.resolved?.did); 95 96 function setCurrentView(view: ExplorerViewState) { 97 explorer.setState("current", view); 98 } 99 100 function updateCurrentView(updater: (draft: ExplorerViewState) => void) { 101 explorer.setState(produce((draft) => { 102 if (!draft.current) return; 103 updater(draft.current); 104 105 if (draft.historyIndex >= 0) { 106 const currentHistory = draft.history[draft.historyIndex]; 107 if (currentHistory && currentHistory !== draft.current) { 108 updater(currentHistory); 109 } 110 } 111 })); 112 } 113 114 async function hydrateLexiconIcons(collections: string[], options?: { force?: boolean }) { 115 const pendingCollections = [...new Set(collections)].filter((collection) => collection.trim().length > 0).filter(( 116 collection, 117 ) => options?.force || !hasCachedLexiconIcon(explorer.state.lexiconIcons, collection)); 118 119 if (pendingCollections.length === 0) { 120 return; 121 } 122 123 try { 124 const icons = await ExplorerController.getLexiconFavicons(pendingCollections); 125 explorer.mergeLexiconIcons(icons); 126 } catch (error) { 127 logger.warn("Failed to load lexicon favicons for explorer", { 128 keyValues: { collections: pendingCollections.join(","), error: String(error) }, 129 }); 130 } 131 } 132 133 function currentLexiconCollections(): string[] { 134 const current = explorer.state.current; 135 if (!current) { 136 return []; 137 } 138 139 if (current.repoData) { 140 return current.repoData.collections.map((collection) => collection.nsid); 141 } 142 143 if (current.collectionData) { 144 return [current.collectionData.collection]; 145 } 146 147 if (current.resolved?.collection) { 148 return [current.resolved.collection]; 149 } 150 151 return []; 152 } 153 154 async function handleResolveInput(input: string) { 155 if (!input.trim()) return; 156 const submittedInput = input.trim(); 157 const requestId = ++resolveRequestId; 158 159 setStatusMessage(null); 160 explorer.setInputValue(submittedInput); 161 setCurrentView({ level: "repo", input: submittedInput, resolved: null, loading: true, error: null, data: null }); 162 163 try { 164 const resolved = await ExplorerController.resolveInput(submittedInput); 165 if (requestId !== resolveRequestId) return; 166 167 const level = resolved.targetKind as ExplorerViewLevel; 168 169 const viewState = { level, input: submittedInput, resolved, loading: true, error: null, data: null }; 170 171 setCurrentView(viewState); 172 explorer.setInputValue(resolved.normalizedInput); 173 174 let finalViewState: ExplorerViewState = viewState; 175 switch (resolved.targetKind) { 176 case "pds": { 177 if (resolved.pdsUrl) { 178 const serverView = await ExplorerController.describeServer(resolved.pdsUrl); 179 finalViewState = { 180 ...viewState, 181 loading: false, 182 pdsData: { repos: serverView.repos, server: serverView.server, cursor: serverView.cursor }, 183 }; 184 } 185 break; 186 } 187 case "repo": { 188 if (resolved.did) { 189 const [repoData, profile] = await Promise.all([ 190 ExplorerController.describeRepo(resolved.did), 191 ProfileController.getProfile(resolved.did).catch(() => null), 192 ]); 193 const profileData = profile?.status === "available" ? profile.profile : null; 194 const collections = extractCollections(repoData); 195 finalViewState = { 196 ...viewState, 197 loading: false, 198 repoData: { 199 collections, 200 did: resolved.did, 201 handle: resolved.handle || resolved.did, 202 pdsUrl: resolved.pdsUrl, 203 socialSummary: profileData 204 ? { 205 followerCount: profileData.followersCount ?? null, 206 followingCount: profileData.followsCount ?? null, 207 } 208 : null, 209 }, 210 }; 211 } 212 break; 213 } 214 case "collection": { 215 if (resolved.did && resolved.collection) { 216 const listData = await ExplorerController.listRecords(resolved.did, resolved.collection); 217 finalViewState = { 218 ...viewState, 219 loading: false, 220 collectionData: { 221 records: (listData.records as Array<Record<string, unknown>>) || [], 222 cursor: (listData.cursor as string) || null, 223 did: resolved.did, 224 collection: resolved.collection, 225 loadingMore: false, 226 }, 227 }; 228 } 229 break; 230 } 231 case "record": { 232 if (resolved.did && resolved.collection && resolved.rkey) { 233 const [recordData, labels] = await Promise.all([ 234 ExplorerController.getRecord(resolved.did, resolved.collection, resolved.rkey), 235 resolved.uri 236 ? ExplorerController.queryLabels(resolved.uri).catch(() => ({ labels: [] })) 237 : Promise.resolve({ labels: [] }), 238 ]); 239 finalViewState = { 240 ...viewState, 241 loading: false, 242 recordData: { 243 record: (recordData.value as Record<string, unknown>) || {}, 244 cid: (recordData.cid as string) || null, 245 uri: resolved.uri || "", 246 labels: (labels.labels as Array<Record<string, unknown>>) || [], 247 }, 248 }; 249 } 250 break; 251 } 252 } 253 254 if (requestId !== resolveRequestId) return; 255 explorer.pushView(finalViewState); 256 257 if (finalViewState.repoData) { 258 void hydrateLexiconIcons(finalViewState.repoData.collections.map((collection) => collection.nsid)); 259 } else if (finalViewState.collectionData) { 260 void hydrateLexiconIcons([finalViewState.collectionData.collection]); 261 } 262 } catch (error) { 263 if (requestId !== resolveRequestId) return; 264 setCurrentView({ 265 level: "repo", 266 input: submittedInput, 267 resolved: null, 268 loading: false, 269 error: String(error), 270 data: null, 271 }); 272 } 273 } 274 275 function handleBack() { 276 if (explorer.goBack()) { 277 const current = explorer.state.current; 278 if (current) { 279 explorer.setInputValue(current.resolved?.normalizedInput || current.input); 280 } 281 } 282 } 283 284 function handleForward() { 285 if (explorer.goForward()) { 286 const current = explorer.state.current; 287 if (current) { 288 explorer.setInputValue(current.resolved?.normalizedInput || current.input); 289 } 290 } 291 } 292 293 function handleNavigateUp() { 294 const current = explorer.state.current; 295 if (!current?.resolved) return; 296 297 const parentInput = resolveParentInput(current); 298 299 if (parentInput) { 300 void handleResolveInput(parentInput); 301 } 302 } 303 304 function handleBreadcrumbClick(level: ExplorerTargetKind) { 305 const current = explorer.state.current; 306 if (!current?.resolved) return; 307 308 const resolved = current.resolved; 309 let targetInput: string | null = null; 310 311 switch (level) { 312 case "pds": { 313 if (resolved.pdsUrl) targetInput = resolved.pdsUrl; 314 break; 315 } 316 case "repo": { 317 if (resolved.did) targetInput = `at://${resolved.did}`; 318 break; 319 } 320 case "collection": { 321 if (resolved.did && resolved.collection) { 322 targetInput = `at://${resolved.did}/${resolved.collection}`; 323 } 324 break; 325 } 326 case "record": { 327 if (resolved.uri) targetInput = resolved.uri; 328 break; 329 } 330 } 331 332 if (targetInput) { 333 void handleResolveInput(targetInput); 334 } 335 } 336 337 async function handleLoadMore() { 338 const current = explorer.state.current; 339 const collectionData = current?.collectionData; 340 if (!collectionData?.cursor || collectionData.loadingMore) return; 341 342 updateCurrentView((draft) => { 343 if (draft.collectionData) { 344 draft.collectionData.loadingMore = true; 345 } 346 }); 347 348 try { 349 const nextPage = await ExplorerController.listRecords( 350 collectionData.did, 351 collectionData.collection, 352 collectionData.cursor, 353 ); 354 const nextRecords = (nextPage.records as Array<Record<string, unknown>>) || []; 355 const nextCursor = (nextPage.cursor as string) || null; 356 357 updateCurrentView((draft) => { 358 if (!draft.collectionData) return; 359 draft.collectionData.records = [...draft.collectionData.records, ...nextRecords]; 360 draft.collectionData.cursor = nextCursor; 361 draft.collectionData.loadingMore = false; 362 }); 363 } catch (error) { 364 updateCurrentView((draft) => { 365 if (draft.collectionData) { 366 draft.collectionData.loadingMore = false; 367 } 368 }); 369 setStatusMessage({ kind: "error", text: String(error) }); 370 } 371 } 372 373 async function handleExport() { 374 const did = explorer.state.current?.resolved?.did; 375 if (!did) return; 376 377 try { 378 const result = await ExplorerController.exportRepoCar(did); 379 setStatusMessage({ kind: "success", text: `Saved CAR export to ${result.path}` }); 380 } catch (error) { 381 setStatusMessage({ kind: "error", text: String(error) }); 382 } 383 } 384 385 async function handleClearIconCache() { 386 if (clearingIconCache()) { 387 return; 388 } 389 390 setClearingIconCache(true); 391 setStatusMessage(null); 392 393 try { 394 await ExplorerController.clearLexiconFaviconCache(); 395 explorer.resetLexiconIcons(); 396 setStatusMessage({ kind: "success", text: "Cleared explorer icon cache." }); 397 398 const collections = currentLexiconCollections(); 399 if (collections.length > 0) { 400 await hydrateLexiconIcons(collections, { force: true }); 401 } 402 } catch (error) { 403 setStatusMessage({ kind: "error", text: String(error) }); 404 } finally { 405 setClearingIconCache(false); 406 } 407 } 408 409 function handleRepoClick(did: string) { 410 void handleResolveInput(`at://${did}`); 411 } 412 413 function handleCollectionClick(did: string, collection: string) { 414 void handleResolveInput(`at://${did}/${collection}`); 415 } 416 417 function handleRecordClick(did: string, collection: string, rkey: string) { 418 void handleResolveInput(`at://${did}/${collection}/${rkey}`); 419 } 420 421 function handleKeyDown(event: KeyboardEvent) { 422 if ((event.metaKey || event.ctrlKey) && event.key === "l") { 423 event.preventDefault(); 424 const input = document.querySelector("[data-explorer-input]") as HTMLInputElement; 425 input?.focus(); 426 input?.select(); 427 return; 428 } 429 430 if ( 431 event.key === "Backspace" 432 && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) 433 ) { 434 event.preventDefault(); 435 handleNavigateUp(); 436 return; 437 } 438 439 if ((event.metaKey || event.ctrlKey) && event.key === "[") { 440 event.preventDefault(); 441 handleBack(); 442 return; 443 } 444 445 if ((event.metaKey || event.ctrlKey) && event.key === "]") { 446 event.preventDefault(); 447 handleForward(); 448 return; 449 } 450 } 451 452 onMount(() => { 453 let unlisten: (() => void) | undefined; 454 const pendingTarget = consumeQueuedExplorerTarget() ?? parseExplorerTargetFromHash(globalThis.location.hash); 455 456 void listen<ExplorerNavigation>(NAVIGATION_EVENT, (event) => { 457 const target = event.payload.target; 458 void handleResolveInput(target.uri ?? target.normalizedInput); 459 }).then((dispose) => { 460 unlisten = dispose; 461 }); 462 463 document.addEventListener("keydown", handleKeyDown); 464 465 if (pendingTarget) { 466 void handleResolveInput(pendingTarget); 467 } 468 469 onCleanup(() => { 470 unlisten?.(); 471 document.removeEventListener("keydown", handleKeyDown); 472 }); 473 }); 474 475 const currentView = createMemo(() => explorer.state.current); 476 477 return ( 478 <div class="flex h-full flex-col overflow-hidden"> 479 <ExplorerUrlBar 480 value={explorer.state.inputValue} 481 canGoBack={canGoBack()} 482 canGoForward={canGoForward()} 483 canExport={canExport()} 484 clearingIconCache={clearingIconCache()} 485 onInput={explorer.setInputValue} 486 onSubmit={handleResolveInput} 487 onBack={handleBack} 488 onForward={handleForward} 489 onClearIconCache={handleClearIconCache} 490 onExport={handleExport} /> 491 492 <Show when={breadcrumb().length > 0}> 493 <ExplorerBreadcrumb items={breadcrumb()} onNavigate={handleBreadcrumbClick} /> 494 </Show> 495 496 <Show when={statusMessage()}> 497 {(message) => ( 498 <div class="px-6 pt-4"> 499 <div 500 class="rounded-2xl px-4 py-3 text-sm shadow-(--inset-shadow)" 501 classList={{ 502 "bg-error-surface text-error": message().kind === "error", 503 "bg-surface-container-high text-on-surface": message().kind === "success", 504 }}> 505 {message().text} 506 </div> 507 </div> 508 )} 509 </Show> 510 511 <div class="flex-1 overflow-hidden"> 512 <Show when={currentView()} fallback={<InitialEmptyPanel onExampleClick={handleResolveInput} />}> 513 {(view) => ( 514 <Presence exitBeforeEnter> 515 <Motion.div 516 initial={{ opacity: 0, y: 8 }} 517 animate={{ opacity: 1, y: 0 }} 518 exit={{ opacity: 0, y: -8 }} 519 transition={{ duration: 0.2 }} 520 class="h-full overflow-auto p-6"> 521 <Switch> 522 <Match when={view().error}> 523 <div class="rounded-3xl bg-error-surface p-4 text-sm text-error shadow-(--inset-shadow)"> 524 {view().error} 525 </div> 526 </Match> 527 528 <Match when={view().loading}> 529 <ExplorerSkeleton /> 530 </Match> 531 532 <Match when={view().level === "pds" && view().pdsData}> 533 <PdsView 534 server={view().pdsData!.server} 535 repos={view().pdsData!.repos} 536 onRepoClick={handleRepoClick} /> 537 </Match> 538 539 <Match when={view().level === "repo" && view().repoData}> 540 <RepoView 541 collections={view().repoData!.collections} 542 did={view().repoData!.did} 543 handle={view().repoData!.handle} 544 lexiconIcons={explorer.state.lexiconIcons} 545 onCollectionClick={(collection: string) => 546 handleCollectionClick(view().repoData!.did, collection)} 547 pdsUrl={view().repoData!.pdsUrl} 548 onPdsClick={() => { 549 const pdsUrl = view().repoData?.pdsUrl; 550 if (pdsUrl) { 551 void handleResolveInput(pdsUrl); 552 } 553 }} 554 socialSummary={view().repoData!.socialSummary} /> 555 </Match> 556 557 <Match when={view().level === "collection" && view().collectionData}> 558 <CollectionView 559 did={view().collectionData!.did} 560 collection={view().collectionData!.collection} 561 lexiconIcon={explorer.state.lexiconIcons[view().collectionData!.collection] ?? null} 562 records={view().collectionData!.records} 563 cursor={view().collectionData!.cursor} 564 loadingMore={view().collectionData!.loadingMore} 565 onLoadMore={handleLoadMore} 566 onRecordClick={(rkey) => 567 handleRecordClick(view().collectionData!.did, view().collectionData!.collection, rkey)} /> 568 </Match> 569 570 <Match when={view().level === "record" && view().recordData}> 571 <RecordView 572 record={view().recordData!.record} 573 cid={view().recordData!.cid} 574 uri={view().recordData!.uri} 575 labels={view().recordData!.labels} /> 576 </Match> 577 578 <Match when={!view().loading && !view().error}> 579 <EmptyPanel /> 580 </Match> 581 </Switch> 582 </Motion.div> 583 </Presence> 584 )} 585 </Show> 586 </div> 587 </div> 588 ); 589} 590 591function InitialEmptyPanel(props: { onExampleClick: (value: string) => void | Promise<void> }) { 592 const examples = [{ label: "@handle", value: "@alice.bsky.social" }, { label: "did", value: "did:plc:alice" }, { 593 label: "at://", 594 value: "at://did:plc:alice/app.bsky.feed.post/123", 595 }, { label: "PDS URL", value: "https://pds.example.com" }]; 596 597 return ( 598 <div class="flex h-full items-start overflow-auto p-6"> 599 <section class="mx-auto grid w-full max-w-4xl gap-6 rounded-[1.75rem] bg-surface-container p-8 shadow-(--inset-shadow)"> 600 <div class="grid gap-2"> 601 <p class="overline-copy text-xs text-primary/80">AT Protocol Explorer</p> 602 <h1 class="m-0 text-[2rem] font-medium tracking-[-0.03em] text-on-surface"> 603 Start from a handle, DID, URI, or PDS. 604 </h1> 605 <p class="m-0 max-w-2xl text-sm leading-6 text-on-surface-variant"> 606 Browse repositories, collections, records, and server metadata without leaving Lazurite. 607 </p> 608 </div> 609 610 <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> 611 <For each={examples}> 612 {(example) => ( 613 <button 614 type="button" 615 onClick={() => void props.onExampleClick(example.value)} 616 class="rounded-2xl bg-surface-container-high px-4 py-4 text-left shadow-(--inset-shadow) transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright"> 617 <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{example.label}</p> 618 <p class="mt-2 truncate text-sm font-mono text-primary">{example.value}</p> 619 </button> 620 )} 621 </For> 622 </div> 623 624 <p class="m-0 text-xs text-on-surface-variant"> 625 Tip: start with <span class="font-mono text-primary">@</span> to get handle suggestions in the explorer bar. 626 </p> 627 </section> 628 </div> 629 ); 630} 631 632function EmptyPanel() { 633 return ( 634 <div class="grid min-h-96 place-items-center"> 635 <div class="text-center"> 636 <p class="text-lg font-medium text-on-surface">Enter an AT URI to explore</p> 637 <p class="text-sm text-on-surface-variant mt-2">Try: at://did:plc:xyz/app.bsky.feed.post/123</p> 638 </div> 639 </div> 640 ); 641} 642 643function ExplorerSkeleton() { 644 return ( 645 <div class="grid gap-4" aria-hidden> 646 <div class="skeleton-block h-8 w-1/3 rounded-lg" /> 647 <div class="skeleton-block h-4 w-1/4 rounded" /> 648 <div class="grid gap-2 mt-4"> 649 <For each={Array.from({ length: 5 })}>{() => <div class="skeleton-block h-16 rounded-xl" />}</For> 650 </div> 651 </div> 652 ); 653}