/** * Renders the results of a search, in a tab for dumps, one for users and one * for playlists, the dumps and the playlists narrowed to a category of their * own. * * @module */ import { useCallback, useLayoutEffect, useRef } from "react"; import { Link, useSearchParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AppHeader } from "../components/layout/AppHeader.tsx"; import { TabBar } from "../components/shared/TabBar.tsx"; import { DumpCardList } from "../components/dump/DumpCardList.tsx"; import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; import { useTabParam } from "../hooks/shared/useTabParam.ts"; import { deserializeDump, deserializePlaylist, deserializePublicUser, } from "../utils/shared/deserialize.ts"; import type { Dump } from "../../shared/dump.ts"; import type { Playlist } from "../../shared/playlist.ts"; import type { PublicUser } from "../../shared/user.ts"; import type { Wire } from "../../shared/api.ts"; import type { SearchResults } from "../../shared/search.ts"; import type { CategoryKind } from "../../shared/category.ts"; import { SEARCH_URL } from "../config/api.ts"; import { expectOk } from "../utils/shared/api.ts"; import { canLoadMore, firstPage, type PagedList, pageUrl, refetchPages, } from "../utils/feed/pagedList.ts"; import { useFetchState } from "../hooks/shared/useFetchState.ts"; import { useLoadMore } from "../hooks/feed/usePagedList.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts"; import { useOnReconnect } from "../hooks/ws/useWS.ts"; import { SEARCH_TABS, searchPath, type SearchTab as Tab, userPath, } from "../../shared/paths.ts"; import { NotFound } from "./NotFound.tsx"; import { markdownToPlainText } from "../../shared/markdown.ts"; /** * Held by `Search` for a query, from the first page of `SearchResults`. The * dumps take the next pages. */ interface Results { dumps: PagedList; /** It counts every match, which the pages loaded so far may not reach. */ dumpTotal: number; users: PublicUser[]; playlists: Playlist[]; } /** Returns the first page of a search's answer, as `Search` holds it. */ function toResults( { dumps, users, playlists }: Wire, ): Results { return { dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore), dumpTotal: dumps.total ?? 0, users: users.map(deserializePublicUser), playlists: playlists.map(deserializePlaylist), }; } /** * Names, for each tab a category narrows, the kind of its categories and the * parameter of `GET /api/search` that takes one. */ const TAB_CATEGORIES: Partial< Record > = { dumps: { kind: "dump", param: "category" }, playlists: { kind: "playlist", param: "playlistCategory" }, }; /** * Renders `/search?q=` and `/search/:searchTab?q=`, for anyone. The results come * from `GET /api/search`, signed in when the viewer is, so they find their own * private dumps and playlists too. The dumps load a page at a time as the * "dumps" tab scrolls. A new `q` aborts the search under way. The socket keeps * the dumps and playlists found live, as edited or deleted, but adds none; a * reconnection runs the search again, for every page of dumps loaded. * * On the "dumps" and "playlists" tabs, a menu narrows the results to a * category of their kind, kept in the URL's `?category=`, which another tab * drops. */ export function Search(): React.JSX.Element { useDocumentTitle(t`Search`); const [searchParams, setSearchParams] = useSearchParams(); const q = searchParams.get("q") ?? ""; const categorySlug = searchParams.get("category") || undefined; const [tab, setTab] = useTabParam(searchPath(), SEARCH_TABS, "dumps"); const filter = tab ? TAB_CATEGORIES[tab] : undefined; const categoryParam = filter && categorySlug ? filter.param : undefined; const { user, authFetch } = useAuth(); const search = useCallback(async (page: number, signal?: AbortSignal) => { const url = new URL(SEARCH_URL, location.origin); url.searchParams.set("q", q); if (categoryParam && categorySlug) { url.searchParams.set(categoryParam, categorySlug); } return expectOk>( await authFetch(pageUrl(url.toString(), page), { signal }), ); }, [authFetch, q, categoryParam, categorySlug]); const { state, setData } = useFetchState( q.trim() ? `${q}:${user?.id ?? "guest"}:${categoryParam ?? ""}:${ categorySlug ?? "" }` : undefined, (signal) => search(1, signal).then(toResults), ); const dumps = state.status === "loaded" ? state.data.dumps : null; const loadMore = useLoadMore( dumps, useCallback( (fn: (dumps: PagedList) => PagedList) => setData((d) => ({ ...d, dumps: fn(d.dumps) })), [setData], ), (page) => search(page).then(({ dumps }) => ({ items: dumps.items.map(deserializeDump), hasMore: dumps.hasMore, })), ); useDumpListSync( dumps?.items ?? [], useCallback( (fn: (items: Dump[]) => Dump[]) => setData((d) => ({ ...d, dumps: { ...d.dumps, items: fn(d.dumps.items) }, })), [setData], ), // Signed in, the results hold the user's own private dumps and playlists. { isOwner: true }, ); usePlaylistListSync( state.status === "loaded" ? state.data.playlists : [], useCallback( (fn: (items: Playlist[]) => Playlist[]) => setData((d) => ({ ...d, playlists: fn(d.playlists) })), [setData], ), { isOwner: true }, ); // What changed while the socket was down: every page of dumps loaded is // fetched again, and the users and playlists, which come with the first. const searchRef = useRef(search); useLayoutEffect(() => { searchRef.current = search; }); useOnReconnect(() => { if (state.status !== "loaded") return; const asked = search; let first: Wire | undefined; refetchPages(state.data.dumps.page, (page) => asked(page).then((res) => { if (page === 1) first = res; return { items: res.dumps.items.map(deserializeDump), hasMore: res.dumps.hasMore, }; })) .then(({ items, hasMore }) => { // The query changed meanwhile, and its own results are on screen. if (!first || asked !== searchRef.current) return; const fresh = toResults(first); setData((d) => ({ ...fresh, dumps: { ...d.dumps, items, hasMore } })); }) .catch(() => {}); }); const sentinelRef = useInfiniteScroll( loadMore, tab === "dumps" && canLoadMore(dumps), ); const results = state.status === "loaded" ? state.data : null; const dumpCount = results ? results.dumpTotal : null; const userCount = results ? results.users.length : null; const playlistCount = results ? results.playlists.length : null; function tabLabel(tab: Tab, count: number | null) { const label = tab === "dumps" ? t`Dumps` : tab === "users" ? t`Users` : t`Playlists`; return count !== null ? t`${label} (${count})` : label; } if (!tab) return ; return (
{q && ( ({ key, label: tabLabel( key, key === "dumps" ? dumpCount : key === "users" ? userCount : playlistCount, ), }))} activeTab={tab} onChange={(next) => { // Each tab's categories are its own. const params = new URLSearchParams(searchParams); params.delete("category"); const query = params.toString(); setTab(next, query ? `?${query}` : ""); }} className="search-tabs" innerClassName="search-tabs-inner" /> )} {q.trim() && filter && (
setSearchParams((p) => { if (slug) p.set("category", slug); else p.delete("category"); return p; })} />
)} {!q.trim() && (

Enter a query to search.

)} {q.trim() && state.status === "loading" && (

Searching…

)} {state.status === "error" && ( )} {results && tab === "dumps" && ( results.dumps.items.length === 0 ?

{t`No dumps match "${q}".`}

: ( <> ) )} {results && tab === "users" && ( results.users.length === 0 ?

{t`No users match "${q}".`}

: (
    {results.users.map((u) => { // The profile renders the description's Markdown; this // line has room for its text only. const description = u.description && markdownToPlainText(u.description, ""); return (
  • @{u.username} {description && ( {description} )}
  • ); })}
) )} {results && tab === "playlists" && ( results.playlists.length === 0 ? (

{t`No playlists match "${q}".`}

) : )}
); }