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 655 lines 23 kB view raw
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}