BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
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}