BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { PostCard } from "$/components/feeds/PostCard";
2import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation";
3import { LocalPostResultsSkeletons } from "$/components/search/LocalPostResultsList";
4import { SearchEmptyState } from "$/components/search/SearchEmptyState";
5import { SearchQueryInput } from "$/components/search/SearchQueryInput";
6import { Icon, LoadingIcon } from "$/components/shared/Icon";
7import { PostCount } from "$/components/shared/PostCount";
8import { useAppSession } from "$/contexts/app-session";
9import { SearchController } from "$/lib/api/search";
10import type { LocalPostResult, SavedPostSource, SyncStatus } from "$/lib/api/types/search";
11import { subscribeBookmarkChanged } from "$/lib/post-events";
12import type { PostView } from "$/lib/types";
13import { formatRelativeTime } from "$/lib/utils/text";
14import { normalizeError } from "$/lib/utils/text";
15import * as logger from "@tauri-apps/plugin-log";
16import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js";
17import { createStore } from "solid-js/store";
18import { Motion, Presence } from "solid-motionone";
19
20const PAGE_SIZE = 50;
21
22const SEARCH_DEBOUNCE_MS = 300;
23
24type TabKey = SavedPostSource;
25
26type TabState = {
27 error: string | null;
28 items: LocalPostResult[];
29 loaded: boolean;
30 loading: boolean;
31 loadingMore: boolean;
32 nextOffset: number | null;
33 total: number;
34};
35
36type SearchTabState = {
37 error: string | null;
38 items: LocalPostResult[];
39 loadedQuery: string | null;
40 loading: boolean;
41 loadingMore: boolean;
42 nextOffset: number | null;
43 total: number;
44};
45
46type SavedPanelState = {
47 query: string;
48 refreshing: boolean;
49 searchTabs: Record<TabKey, SearchTabState>;
50 syncStatus: SyncStatus[];
51 syncStatusLoading: boolean;
52 tabs: Record<TabKey, TabState>;
53};
54
55const TAB_ITEMS: Array<{ key: TabKey; label: string }> = [{ key: "bookmark", label: "Saved" }, {
56 key: "like",
57 label: "Liked",
58}];
59
60function createTabState(): TabState {
61 return { error: null, items: [], loaded: false, loading: false, loadingMore: false, nextOffset: null, total: 0 };
62}
63
64function createSearchTabState(): SearchTabState {
65 return { error: null, items: [], loadedQuery: null, loading: false, loadingMore: false, nextOffset: null, total: 0 };
66}
67
68function createPanelState(): SavedPanelState {
69 return {
70 query: "",
71 refreshing: false,
72 searchTabs: { bookmark: createSearchTabState(), like: createSearchTabState() },
73 syncStatus: [],
74 syncStatusLoading: false,
75 tabs: { bookmark: createTabState(), like: createTabState() },
76 };
77}
78
79function LoadMoreButton(props: { next: number | null; onLoadMore: () => void; loadingMore: boolean }) {
80 return (
81 <Show when={props.next}>
82 <div class="flex justify-center pt-2">
83 <button
84 type="button"
85 class="inline-flex items-center gap-2 rounded-full border-0 bg-surface px-4 py-2.5 text-sm font-medium text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-60"
86 disabled={props.loadingMore}
87 onClick={() => props.onLoadMore()}>
88 <Show
89 when={props.loadingMore}
90 fallback={
91 <>
92 <Icon kind="bookmark" aria-hidden />
93 Load More
94 </>
95 }>
96 <LoadingIcon isLoading aria-hidden />
97 Loading more...
98 </Show>
99 </button>
100 </div>
101 </Show>
102 );
103}
104
105function SavedPostsMessage(props: { body: string; title: string }) {
106 return (
107 <Motion.div
108 class="grid place-items-center px-6 py-16"
109 initial={{ opacity: 0 }}
110 animate={{ opacity: 1 }}
111 exit={{ opacity: 0 }}
112 transition={{ duration: 0.15 }}>
113 <div class="grid max-w-md gap-3 text-center">
114 <p class="m-0 text-base font-medium text-on-surface">{props.title}</p>
115 <p class="m-0 text-sm text-on-surface-variant">{props.body}</p>
116 </div>
117 </Motion.div>
118 );
119}
120
121export function SavedPostsPanel() {
122 const session = useAppSession();
123 const postNavigation = usePostNavigation();
124 const [activeTab, setActiveTab] = createSignal<TabKey>("bookmark");
125 const [state, setState] = createStore<SavedPanelState>(createPanelState());
126 const browseRequestIds: Record<TabKey, number> = { bookmark: 0, like: 0 };
127 const searchRequestIds: Record<TabKey, number> = { bookmark: 0, like: 0 };
128 const trimmedQuery = createMemo(() => state.query.trim());
129 const isSearching = createMemo(() => trimmedQuery().length > 0);
130 const activeTabState = createMemo(() => state.tabs[activeTab()]);
131 const activeSearchState = createMemo(() => state.searchTabs[activeTab()]);
132 const statusBySource = createMemo(() =>
133 Object.fromEntries(state.syncStatus.map((status) => [status.source, status])) as Partial<Record<TabKey, SyncStatus>>
134 );
135 const totalIndexedPosts = createMemo(() =>
136 state.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0)
137 );
138 const lastSync = createMemo(() => {
139 const timestamps = state.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[];
140 if (timestamps.length === 0) {
141 return null;
142 }
143
144 return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]);
145 });
146 const activeResultCount = createMemo(() => isSearching() ? activeSearchState().total : activeTabState().total);
147
148 let activeDid: string | null = null;
149 let debounceTimer: ReturnType<typeof setTimeout> | undefined;
150 let searchInputRef: HTMLInputElement | undefined;
151
152 createEffect(() => {
153 void refreshForDid(session.activeDid);
154 });
155
156 onCleanup(() => clearTimeout(debounceTimer));
157
158 createEffect(() => {
159 const dispose = subscribeBookmarkChanged((detail) => {
160 setState("tabs", "bookmark", "items", (items) => updateBookmarkResults(items, detail.uri, detail.bookmarked));
161 setState(
162 "searchTabs",
163 "bookmark",
164 "items",
165 (items) => updateBookmarkResults(items, detail.uri, detail.bookmarked),
166 );
167 setState("tabs", "bookmark", "total", (current) => adjustBookmarkTotal(current, detail.bookmarked));
168 setState("searchTabs", "bookmark", "total", (current) => adjustBookmarkTotal(current, detail.bookmarked));
169 });
170 onCleanup(dispose);
171 });
172
173 async function refreshForDid(did: string | null) {
174 if (did === activeDid) {
175 return;
176 }
177
178 activeDid = did;
179 setActiveTab("bookmark");
180 setState(createPanelState());
181
182 if (!did) {
183 return;
184 }
185
186 await Promise.all([loadSyncStatus(did), ensureActiveViewLoaded("bookmark", did)]);
187 }
188
189 async function loadSyncStatus(did = session.activeDid) {
190 if (!did) {
191 setState("syncStatus", []);
192 return;
193 }
194
195 setState("syncStatusLoading", true);
196
197 try {
198 const status = await SearchController.getSyncStatus(did);
199 if (did !== activeDid) {
200 return;
201 }
202
203 setState("syncStatus", status);
204 } catch (error) {
205 logger.error("failed to load saved-post sync status", { keyValues: { did, error: normalizeError(error) } });
206 } finally {
207 if (did === activeDid) {
208 setState("syncStatusLoading", false);
209 }
210 }
211 }
212
213 async function ensureActiveViewLoaded(source: TabKey, did = session.activeDid) {
214 if (isSearching()) {
215 await ensureSearchLoaded(source, trimmedQuery(), did);
216 return;
217 }
218
219 await ensureBrowseLoaded(source, did);
220 }
221
222 async function ensureBrowseLoaded(source: TabKey, did = session.activeDid) {
223 if (!did || state.tabs[source].loaded || state.tabs[source].loading) {
224 return;
225 }
226
227 await loadBrowseTab(source, { did });
228 }
229
230 async function ensureSearchLoaded(source: TabKey, query: string, did = session.activeDid) {
231 if (!did || !query) {
232 return;
233 }
234
235 const current = state.searchTabs[source];
236 if (current.loading || current.loadedQuery === query) {
237 return;
238 }
239
240 await loadSearchTab(source, { did, query });
241 }
242
243 async function loadBrowseTab(source: TabKey, options: { append?: boolean; did?: string | null } = {}) {
244 const did = options.did ?? session.activeDid;
245 if (!did) {
246 return;
247 }
248
249 const current = state.tabs[source];
250 const offset = options.append ? current.nextOffset ?? 0 : 0;
251 if (options.append && current.nextOffset === null) {
252 return;
253 }
254
255 const requestId = ++browseRequestIds[source];
256 setState("tabs", source, options.append ? "loadingMore" : "loading", true);
257 setState("tabs", source, "error", null);
258
259 try {
260 const page = await SearchController.listSavedPosts(source, PAGE_SIZE, offset);
261 if (did !== activeDid || requestId !== browseRequestIds[source]) {
262 return;
263 }
264
265 setState("tabs", source, "items", options.append ? [...current.items, ...page.posts] : page.posts);
266 setState("tabs", source, "total", page.total);
267 setState("tabs", source, "nextOffset", page.nextOffset ?? null);
268 setState("tabs", source, "loaded", true);
269 } catch (error) {
270 const message = normalizeError(error);
271 if (did !== activeDid || requestId !== browseRequestIds[source]) {
272 return;
273 }
274
275 setState("tabs", source, "error", message);
276 logger.error("failed to load saved posts", { keyValues: { did, source, error: message } });
277 } finally {
278 if (did === activeDid && requestId === browseRequestIds[source]) {
279 setState("tabs", source, "loading", false);
280 setState("tabs", source, "loadingMore", false);
281 }
282 }
283 }
284
285 async function loadSearchTab(source: TabKey, options: { append?: boolean; did?: string | null; query: string }) {
286 const did = options.did ?? session.activeDid;
287 const query = options.query.trim();
288 if (!did || !query) {
289 return;
290 }
291
292 const current = state.searchTabs[source];
293 const offset = options.append ? current.nextOffset ?? 0 : 0;
294 if (options.append && current.nextOffset === null) {
295 return;
296 }
297
298 const requestId = ++searchRequestIds[source];
299 setState("searchTabs", source, options.append ? "loadingMore" : "loading", true);
300 setState("searchTabs", source, "error", null);
301
302 try {
303 const page = await SearchController.listSavedPosts(source, PAGE_SIZE, offset, query);
304 if (did !== activeDid || requestId !== searchRequestIds[source] || trimmedQuery() !== query) {
305 return;
306 }
307
308 setState("searchTabs", source, "items", options.append ? [...current.items, ...page.posts] : page.posts);
309 setState("searchTabs", source, "total", page.total);
310 setState("searchTabs", source, "nextOffset", page.nextOffset ?? null);
311 setState("searchTabs", source, "loadedQuery", query);
312 } catch (error) {
313 const message = normalizeError(error);
314 if (did !== activeDid || requestId !== searchRequestIds[source] || trimmedQuery() !== query) {
315 return;
316 }
317
318 setState("searchTabs", source, "error", message);
319 logger.error("failed to search saved posts", { keyValues: { did, source, query, error: message } });
320 } finally {
321 if (did === activeDid && requestId === searchRequestIds[source] && trimmedQuery() === query) {
322 setState("searchTabs", source, "loading", false);
323 setState("searchTabs", source, "loadingMore", false);
324 }
325 }
326 }
327
328 function clearSearch() {
329 clearTimeout(debounceTimer);
330 setState("query", "");
331 void ensureBrowseLoaded(activeTab());
332 searchInputRef?.focus();
333 }
334
335 function handleSearchInput(value: string) {
336 setState("query", value);
337 clearTimeout(debounceTimer);
338
339 const nextQuery = value.trim();
340 if (!nextQuery) {
341 void ensureBrowseLoaded(activeTab());
342 return;
343 }
344
345 debounceTimer = setTimeout(() => {
346 void loadSearchTab(activeTab(), { query: nextQuery });
347 }, SEARCH_DEBOUNCE_MS);
348 }
349
350 function handleSearchKeyDown(event: KeyboardEvent) {
351 if (event.key === "Escape" && state.query) {
352 clearSearch();
353 }
354 }
355
356 async function handleSelectTab(source: TabKey) {
357 setActiveTab(source);
358 await ensureActiveViewLoaded(source);
359 }
360
361 async function handleRefresh() {
362 if (!session.activeDid || state.refreshing) {
363 return;
364 }
365
366 setState("refreshing", true);
367
368 try {
369 await SearchController.syncPosts(session.activeDid, "bookmark");
370 await SearchController.syncPosts(session.activeDid, "like");
371 await Promise.all([
372 loadSyncStatus(session.activeDid),
373 isSearching()
374 ? loadSearchTab(activeTab(), { did: session.activeDid, query: trimmedQuery() })
375 : loadBrowseTab(activeTab(), { did: session.activeDid }),
376 ]);
377 } catch (error) {
378 logger.error("failed to refresh saved posts", {
379 keyValues: { did: session.activeDid, error: normalizeError(error) },
380 });
381 } finally {
382 setState("refreshing", false);
383 }
384 }
385
386 return (
387 <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]">
388 <SavedPostsHeader
389 activeResultCount={activeResultCount()}
390 activeTab={activeTab()}
391 counts={{ bookmark: statusBySource().bookmark?.postCount ?? 0, like: statusBySource().like?.postCount ?? 0 }}
392 loading={state.refreshing}
393 onQueryChange={handleSearchInput}
394 onRefresh={() => void handleRefresh()}
395 onSearchClear={clearSearch}
396 onSearchKeyDown={handleSearchKeyDown}
397 onSelectTab={(tab) => void handleSelectTab(tab)}
398 query={state.query}
399 queryRef={(element) => {
400 searchInputRef = element;
401 }}
402 searchLoading={activeSearchState().loading}
403 searching={isSearching()}
404 syncLoading={state.syncStatusLoading}
405 totalIndexedPosts={totalIndexedPosts()}
406 lastSync={lastSync()} />
407 <SavedPostsViewport
408 activeTab={activeTab()}
409 browsingState={activeTabState()}
410 onOpenThread={(uri) => void postNavigation.openPost(uri)}
411 searching={isSearching()}
412 searchingState={activeSearchState()}
413 onLoadMore={() => void (isSearching()
414 ? loadSearchTab(activeTab(), { append: true, query: trimmedQuery() })
415 : loadBrowseTab(activeTab(), { append: true }))} />
416 </article>
417 );
418}
419
420function updateBookmarkResults(items: LocalPostResult[], uri: string, bookmarked: boolean) {
421 if (bookmarked) {
422 return items;
423 }
424
425 return items.filter((item) => item.uri !== uri);
426}
427
428function adjustBookmarkTotal(total: number, bookmarked: boolean) {
429 return bookmarked ? total : Math.max(0, total - 1);
430}
431
432function SavedPostsHeader(
433 props: {
434 activeResultCount: number;
435 activeTab: TabKey;
436 counts: Record<TabKey, number>;
437 lastSync: string | null;
438 loading: boolean;
439 onQueryChange: (value: string) => void;
440 onRefresh: () => void;
441 onSearchClear: () => void;
442 onSearchKeyDown: (event: KeyboardEvent) => void;
443 onSelectTab: (tab: TabKey) => void;
444 query: string;
445 queryRef: (element: HTMLInputElement) => void;
446 searchLoading: boolean;
447 searching: boolean;
448 syncLoading: boolean;
449 totalIndexedPosts: number;
450 },
451) {
452 return (
453 <header class="grid gap-5 px-6 pb-4 pt-6">
454 <div class="flex flex-wrap items-center justify-between gap-4">
455 <div class="grid gap-1">
456 <p class="overline-copy text-xs text-on-surface-variant">Library</p>
457 <h1 class="m-0 text-xl font-semibold tracking-tight text-on-surface">Saved posts</h1>
458 <Show
459 when={props.syncLoading}
460 fallback={<PostCount totalPosts={props.totalIndexedPosts} lastSync={props.lastSync} inline />}>
461 <p class="m-0 text-xs text-on-surface-variant">Loading sync status...</p>
462 </Show>
463 </div>
464
465 <button
466 type="button"
467 class="inline-flex h-10 items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 text-sm font-medium text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-60"
468 disabled={props.loading}
469 onClick={() => props.onRefresh()}>
470 <LoadingIcon isLoading={props.loading} aria-hidden fallback={<Icon kind="refresh" aria-hidden />} />
471 <Show when={props.loading} fallback="Refresh">Refreshing...</Show>
472 </button>
473 </div>
474
475 <SearchQueryInput
476 actions={{ onClear: props.onSearchClear, onKeyDown: props.onSearchKeyDown, onQueryChange: props.onQueryChange }}
477 refs={{ inputRef: props.queryRef }}
478 state={{
479 error: null,
480 loading: props.searchLoading,
481 placeholder: props.activeTab === "bookmark" ? "Search saved posts..." : "Search liked posts...",
482 query: props.query,
483 }} />
484
485 <div class="flex items-center justify-between gap-4">
486 <nav class="flex flex-wrap gap-2" aria-label="Saved post tabs">
487 <For each={TAB_ITEMS}>
488 {(tab) => (
489 <button
490 type="button"
491 aria-pressed={props.activeTab === tab.key}
492 class="inline-flex items-center gap-2 rounded-full border-0 px-4 py-2.5 text-sm font-medium transition duration-150"
493 classList={{
494 "bg-surface text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.18)]":
495 props.activeTab === tab.key,
496 "bg-transparent text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface":
497 props.activeTab !== tab.key,
498 }}
499 onClick={() => props.onSelectTab(tab.key)}>
500 {tab.label}
501 <span class="min-w-5 rounded-full bg-white/10 px-1.5 py-0.5 text-center text-[0.7rem] leading-none">
502 {props.counts[tab.key]}
503 </span>
504 </button>
505 )}
506 </For>
507 </nav>
508
509 <span class="text-xs text-on-surface-variant">
510 <Show
511 when={props.searching}
512 fallback={`Browsing ${props.activeTab === "bookmark" ? "saved" : "liked"} posts`}>
513 Found <span class="font-medium text-on-surface">{props.activeResultCount}</span> matches
514 </Show>
515 </span>
516 </div>
517 </header>
518 );
519}
520
521function SavedPostsViewport(
522 props: {
523 activeTab: TabKey;
524 browsingState: TabState;
525 onOpenThread: (uri: string) => void;
526 onLoadMore: () => void;
527 searching: boolean;
528 searchingState: SearchTabState;
529 },
530) {
531 return (
532 <div class="min-h-0 overflow-y-auto px-3 pb-3">
533 <Presence>
534 <Show when={props.activeTab === "bookmark"} keyed>
535 <SavedPostsBody
536 browsingState={props.browsingState}
537 onOpenThread={props.onOpenThread}
538 onLoadMore={props.onLoadMore}
539 searching={props.searching}
540 searchingState={props.searchingState}
541 source={props.activeTab} />
542 </Show>
543 <Show when={props.activeTab === "like"} keyed>
544 <SavedPostsBody
545 browsingState={props.browsingState}
546 onOpenThread={props.onOpenThread}
547 onLoadMore={props.onLoadMore}
548 searching={props.searching}
549 searchingState={props.searchingState}
550 source={props.activeTab} />
551 </Show>
552 </Presence>
553 </div>
554 );
555}
556
557function SavedPostsBody(
558 props: {
559 browsingState: TabState;
560 onOpenThread: (uri: string) => void;
561 onLoadMore: () => void;
562 searching: boolean;
563 searchingState: SearchTabState;
564 source: TabKey;
565 },
566) {
567 const activeState = createMemo(() => props.searching ? props.searchingState : props.browsingState);
568 const emptyTitle = createMemo(() =>
569 props.searching
570 ? `No ${props.source === "bookmark" ? "saved" : "liked"} matches found`
571 : `No ${props.source === "bookmark" ? "bookmarked" : "liked"} posts synced yet.`
572 );
573
574 return (
575 <Motion.div
576 class="grid gap-3"
577 initial={{ opacity: 0 }}
578 animate={{ opacity: 1 }}
579 exit={{ opacity: 0 }}
580 transition={{ duration: 0.15 }}>
581 <Switch>
582 <Match when={activeState().loading && activeState().items.length === 0}>
583 <LocalPostResultsSkeletons count={4} />
584 </Match>
585 <Match when={!!activeState().error}>
586 <SavedPostsMessage
587 body="Try the query again or refresh after syncing if the local archive is stale."
588 title={activeState().error ?? "Search failed"} />
589 </Match>
590 <Match when={props.searching && activeState().items.length === 0}>
591 <Motion.div
592 class="grid place-items-center px-6 py-16"
593 initial={{ opacity: 0 }}
594 animate={{ opacity: 1 }}
595 exit={{ opacity: 0 }}
596 transition={{ duration: 0.15 }}>
597 <SearchEmptyState reason="no-results" scope="local" />
598 </Motion.div>
599 </Match>
600 <Match when={!props.searching && activeState().items.length === 0}>
601 <SavedPostsMessage
602 body={`Refresh after syncing to populate your ${props.source === "bookmark" ? "saved" : "liked"} archive.`}
603 title={emptyTitle()} />
604 </Match>
605 <Match when={activeState().items.length > 0}>
606 <div class="grid gap-3">
607 <SavedPostsResultsList onOpenThread={props.onOpenThread} results={activeState().items} />
608 <LoadMoreButton
609 next={activeState().nextOffset}
610 onLoadMore={props.onLoadMore}
611 loadingMore={activeState().loadingMore} />
612 </div>
613 </Match>
614 </Switch>
615 </Motion.div>
616 );
617}
618
619function SavedPostsResultsList(props: { onOpenThread: (uri: string) => void; results: LocalPostResult[] }) {
620 return (
621 <Motion.div
622 class="grid gap-2"
623 initial={{ opacity: 0 }}
624 animate={{ opacity: 1 }}
625 exit={{ opacity: 0 }}
626 transition={{ duration: 0.15 }}>
627 <For each={props.results}>
628 {(result, index) => (
629 <Motion.div
630 initial={{ opacity: 0, y: -6 }}
631 animate={{ opacity: 1, y: 0 }}
632 transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }}>
633 <PostCard
634 post={toSavedPost(result)}
635 showActions={false}
636 onOpenThread={(uri) => props.onOpenThread(uri)} />
637 </Motion.div>
638 )}
639 </For>
640 </Motion.div>
641 );
642}
643
644function toSavedPost(result: LocalPostResult): PostView {
645 const handle = result.authorHandle?.trim() || result.authorDid;
646 const createdAt = result.createdAt ?? "";
647
648 return {
649 author: { did: result.authorDid, handle, displayName: handle },
650 cid: result.cid,
651 indexedAt: createdAt,
652 record: { createdAt, text: result.text ?? "" },
653 uri: result.uri,
654 };
655}