import { PostCard } from "$/components/feeds/PostCard"; import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation"; import { LocalPostResultsSkeletons } from "$/components/search/LocalPostResultsList"; import { SearchEmptyState } from "$/components/search/SearchEmptyState"; import { SearchQueryInput } from "$/components/search/SearchQueryInput"; import { Icon, LoadingIcon } from "$/components/shared/Icon"; import { PostCount } from "$/components/shared/PostCount"; import { useAppSession } from "$/contexts/app-session"; import { SearchController } from "$/lib/api/search"; import type { LocalPostResult, SavedPostSource, SyncStatus } from "$/lib/api/types/search"; import { subscribeBookmarkChanged } from "$/lib/post-events"; import type { PostView } from "$/lib/types"; import { formatRelativeTime } from "$/lib/utils/text"; import { normalizeError } from "$/lib/utils/text"; import * as logger from "@tauri-apps/plugin-log"; import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; import { createStore } from "solid-js/store"; import { Motion, Presence } from "solid-motionone"; const PAGE_SIZE = 50; const SEARCH_DEBOUNCE_MS = 300; type TabKey = SavedPostSource; type TabState = { error: string | null; items: LocalPostResult[]; loaded: boolean; loading: boolean; loadingMore: boolean; nextOffset: number | null; total: number; }; type SearchTabState = { error: string | null; items: LocalPostResult[]; loadedQuery: string | null; loading: boolean; loadingMore: boolean; nextOffset: number | null; total: number; }; type SavedPanelState = { query: string; refreshing: boolean; searchTabs: Record; syncStatus: SyncStatus[]; syncStatusLoading: boolean; tabs: Record; }; const TAB_ITEMS: Array<{ key: TabKey; label: string }> = [{ key: "bookmark", label: "Saved" }, { key: "like", label: "Liked", }]; function createTabState(): TabState { return { error: null, items: [], loaded: false, loading: false, loadingMore: false, nextOffset: null, total: 0 }; } function createSearchTabState(): SearchTabState { return { error: null, items: [], loadedQuery: null, loading: false, loadingMore: false, nextOffset: null, total: 0 }; } function createPanelState(): SavedPanelState { return { query: "", refreshing: false, searchTabs: { bookmark: createSearchTabState(), like: createSearchTabState() }, syncStatus: [], syncStatusLoading: false, tabs: { bookmark: createTabState(), like: createTabState() }, }; } function LoadMoreButton(props: { next: number | null; onLoadMore: () => void; loadingMore: boolean }) { return (
); } function SavedPostsMessage(props: { body: string; title: string }) { return (

{props.title}

{props.body}

); } export function SavedPostsPanel() { const session = useAppSession(); const postNavigation = usePostNavigation(); const [activeTab, setActiveTab] = createSignal("bookmark"); const [state, setState] = createStore(createPanelState()); const browseRequestIds: Record = { bookmark: 0, like: 0 }; const searchRequestIds: Record = { bookmark: 0, like: 0 }; const trimmedQuery = createMemo(() => state.query.trim()); const isSearching = createMemo(() => trimmedQuery().length > 0); const activeTabState = createMemo(() => state.tabs[activeTab()]); const activeSearchState = createMemo(() => state.searchTabs[activeTab()]); const statusBySource = createMemo(() => Object.fromEntries(state.syncStatus.map((status) => [status.source, status])) as Partial> ); const totalIndexedPosts = createMemo(() => state.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) ); const lastSync = createMemo(() => { const timestamps = state.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[]; if (timestamps.length === 0) { return null; } return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); }); const activeResultCount = createMemo(() => isSearching() ? activeSearchState().total : activeTabState().total); let activeDid: string | null = null; let debounceTimer: ReturnType | undefined; let searchInputRef: HTMLInputElement | undefined; createEffect(() => { void refreshForDid(session.activeDid); }); onCleanup(() => clearTimeout(debounceTimer)); createEffect(() => { const dispose = subscribeBookmarkChanged((detail) => { setState("tabs", "bookmark", "items", (items) => updateBookmarkResults(items, detail.uri, detail.bookmarked)); setState( "searchTabs", "bookmark", "items", (items) => updateBookmarkResults(items, detail.uri, detail.bookmarked), ); setState("tabs", "bookmark", "total", (current) => adjustBookmarkTotal(current, detail.bookmarked)); setState("searchTabs", "bookmark", "total", (current) => adjustBookmarkTotal(current, detail.bookmarked)); }); onCleanup(dispose); }); async function refreshForDid(did: string | null) { if (did === activeDid) { return; } activeDid = did; setActiveTab("bookmark"); setState(createPanelState()); if (!did) { return; } await Promise.all([loadSyncStatus(did), ensureActiveViewLoaded("bookmark", did)]); } async function loadSyncStatus(did = session.activeDid) { if (!did) { setState("syncStatus", []); return; } setState("syncStatusLoading", true); try { const status = await SearchController.getSyncStatus(did); if (did !== activeDid) { return; } setState("syncStatus", status); } catch (error) { logger.error("failed to load saved-post sync status", { keyValues: { did, error: normalizeError(error) } }); } finally { if (did === activeDid) { setState("syncStatusLoading", false); } } } async function ensureActiveViewLoaded(source: TabKey, did = session.activeDid) { if (isSearching()) { await ensureSearchLoaded(source, trimmedQuery(), did); return; } await ensureBrowseLoaded(source, did); } async function ensureBrowseLoaded(source: TabKey, did = session.activeDid) { if (!did || state.tabs[source].loaded || state.tabs[source].loading) { return; } await loadBrowseTab(source, { did }); } async function ensureSearchLoaded(source: TabKey, query: string, did = session.activeDid) { if (!did || !query) { return; } const current = state.searchTabs[source]; if (current.loading || current.loadedQuery === query) { return; } await loadSearchTab(source, { did, query }); } async function loadBrowseTab(source: TabKey, options: { append?: boolean; did?: string | null } = {}) { const did = options.did ?? session.activeDid; if (!did) { return; } const current = state.tabs[source]; const offset = options.append ? current.nextOffset ?? 0 : 0; if (options.append && current.nextOffset === null) { return; } const requestId = ++browseRequestIds[source]; setState("tabs", source, options.append ? "loadingMore" : "loading", true); setState("tabs", source, "error", null); try { const page = await SearchController.listSavedPosts(source, PAGE_SIZE, offset); if (did !== activeDid || requestId !== browseRequestIds[source]) { return; } setState("tabs", source, "items", options.append ? [...current.items, ...page.posts] : page.posts); setState("tabs", source, "total", page.total); setState("tabs", source, "nextOffset", page.nextOffset ?? null); setState("tabs", source, "loaded", true); } catch (error) { const message = normalizeError(error); if (did !== activeDid || requestId !== browseRequestIds[source]) { return; } setState("tabs", source, "error", message); logger.error("failed to load saved posts", { keyValues: { did, source, error: message } }); } finally { if (did === activeDid && requestId === browseRequestIds[source]) { setState("tabs", source, "loading", false); setState("tabs", source, "loadingMore", false); } } } async function loadSearchTab(source: TabKey, options: { append?: boolean; did?: string | null; query: string }) { const did = options.did ?? session.activeDid; const query = options.query.trim(); if (!did || !query) { return; } const current = state.searchTabs[source]; const offset = options.append ? current.nextOffset ?? 0 : 0; if (options.append && current.nextOffset === null) { return; } const requestId = ++searchRequestIds[source]; setState("searchTabs", source, options.append ? "loadingMore" : "loading", true); setState("searchTabs", source, "error", null); try { const page = await SearchController.listSavedPosts(source, PAGE_SIZE, offset, query); if (did !== activeDid || requestId !== searchRequestIds[source] || trimmedQuery() !== query) { return; } setState("searchTabs", source, "items", options.append ? [...current.items, ...page.posts] : page.posts); setState("searchTabs", source, "total", page.total); setState("searchTabs", source, "nextOffset", page.nextOffset ?? null); setState("searchTabs", source, "loadedQuery", query); } catch (error) { const message = normalizeError(error); if (did !== activeDid || requestId !== searchRequestIds[source] || trimmedQuery() !== query) { return; } setState("searchTabs", source, "error", message); logger.error("failed to search saved posts", { keyValues: { did, source, query, error: message } }); } finally { if (did === activeDid && requestId === searchRequestIds[source] && trimmedQuery() === query) { setState("searchTabs", source, "loading", false); setState("searchTabs", source, "loadingMore", false); } } } function clearSearch() { clearTimeout(debounceTimer); setState("query", ""); void ensureBrowseLoaded(activeTab()); searchInputRef?.focus(); } function handleSearchInput(value: string) { setState("query", value); clearTimeout(debounceTimer); const nextQuery = value.trim(); if (!nextQuery) { void ensureBrowseLoaded(activeTab()); return; } debounceTimer = setTimeout(() => { void loadSearchTab(activeTab(), { query: nextQuery }); }, SEARCH_DEBOUNCE_MS); } function handleSearchKeyDown(event: KeyboardEvent) { if (event.key === "Escape" && state.query) { clearSearch(); } } async function handleSelectTab(source: TabKey) { setActiveTab(source); await ensureActiveViewLoaded(source); } async function handleRefresh() { if (!session.activeDid || state.refreshing) { return; } setState("refreshing", true); try { await SearchController.syncPosts(session.activeDid, "bookmark"); await SearchController.syncPosts(session.activeDid, "like"); await Promise.all([ loadSyncStatus(session.activeDid), isSearching() ? loadSearchTab(activeTab(), { did: session.activeDid, query: trimmedQuery() }) : loadBrowseTab(activeTab(), { did: session.activeDid }), ]); } catch (error) { logger.error("failed to refresh saved posts", { keyValues: { did: session.activeDid, error: normalizeError(error) }, }); } finally { setState("refreshing", false); } } return (
void handleRefresh()} onSearchClear={clearSearch} onSearchKeyDown={handleSearchKeyDown} onSelectTab={(tab) => void handleSelectTab(tab)} query={state.query} queryRef={(element) => { searchInputRef = element; }} searchLoading={activeSearchState().loading} searching={isSearching()} syncLoading={state.syncStatusLoading} totalIndexedPosts={totalIndexedPosts()} lastSync={lastSync()} /> void postNavigation.openPost(uri)} searching={isSearching()} searchingState={activeSearchState()} onLoadMore={() => void (isSearching() ? loadSearchTab(activeTab(), { append: true, query: trimmedQuery() }) : loadBrowseTab(activeTab(), { append: true }))} />
); } function updateBookmarkResults(items: LocalPostResult[], uri: string, bookmarked: boolean) { if (bookmarked) { return items; } return items.filter((item) => item.uri !== uri); } function adjustBookmarkTotal(total: number, bookmarked: boolean) { return bookmarked ? total : Math.max(0, total - 1); } function SavedPostsHeader( props: { activeResultCount: number; activeTab: TabKey; counts: Record; lastSync: string | null; loading: boolean; onQueryChange: (value: string) => void; onRefresh: () => void; onSearchClear: () => void; onSearchKeyDown: (event: KeyboardEvent) => void; onSelectTab: (tab: TabKey) => void; query: string; queryRef: (element: HTMLInputElement) => void; searchLoading: boolean; searching: boolean; syncLoading: boolean; totalIndexedPosts: number; }, ) { return (

Library

Saved posts

}>

Loading sync status...

Found {props.activeResultCount} matches
); } function SavedPostsViewport( props: { activeTab: TabKey; browsingState: TabState; onOpenThread: (uri: string) => void; onLoadMore: () => void; searching: boolean; searchingState: SearchTabState; }, ) { return (
); } function SavedPostsBody( props: { browsingState: TabState; onOpenThread: (uri: string) => void; onLoadMore: () => void; searching: boolean; searchingState: SearchTabState; source: TabKey; }, ) { const activeState = createMemo(() => props.searching ? props.searchingState : props.browsingState); const emptyTitle = createMemo(() => props.searching ? `No ${props.source === "bookmark" ? "saved" : "liked"} matches found` : `No ${props.source === "bookmark" ? "bookmarked" : "liked"} posts synced yet.` ); return ( 0}>
); } function SavedPostsResultsList(props: { onOpenThread: (uri: string) => void; results: LocalPostResult[] }) { return ( {(result, index) => ( props.onOpenThread(uri)} /> )} ); } function toSavedPost(result: LocalPostResult): PostView { const handle = result.authorHandle?.trim() || result.authorDid; const createdAt = result.createdAt ?? ""; return { author: { did: result.authorDid, handle, displayName: handle }, cid: result.cid, indexedAt: createdAt, record: { createdAt, text: result.text ?? "" }, uri: result.uri, }; }