v4: what comes into a live list goes where its date puts it — a dump edited into a category stayed out of the index's feed of it until a reload, and a dump or a playlist made public went on top of the others' lists as if just posted. The index's feed now takes a dump edited into its scope, and orders the dumps that arrive by date, as the server pages them; a created playlist goes by date in the profile's and /playlists' lists, a new one first as before. Both use withByDate (utils/feed/pagedList.ts), which leaves an item older than every one loaded to a later page. The search runs again after a reconnection, for every page of dumps loaded, with its users and playlists; it shares refetchPages with usePagedList's resync. injectDump's doc said a private dump is never broadcast, while its poster gets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 07:43:28 +00:00
parent 308a8299f3
commit 7c00db2b84
11 changed files with 278 additions and 61 deletions

View File

@@ -6,7 +6,7 @@
* @module
*/
import { useCallback } from "react";
import { useCallback, useLayoutEffect, useRef } from "react";
import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -39,11 +39,13 @@ import {
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,
@@ -64,6 +66,18 @@ interface Results {
playlists: Playlist[];
}
/** Returns the first page of a search's answer, as `Search` holds it. */
function toResults(
{ dumps, users, playlists }: Wire<SearchResults>,
): 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.
@@ -80,7 +94,8 @@ const TAB_CATEGORIES: Partial<
* 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.
* 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
@@ -113,13 +128,7 @@ export function Search(): React.JSX.Element {
categorySlug ?? ""
}`
: undefined,
(signal) =>
search(1, signal).then(({ dumps, users, playlists }) => ({
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
dumpTotal: dumps.total ?? 0,
users: users.map(deserializePublicUser),
playlists: playlists.map(deserializePlaylist),
})),
(signal) => search(1, signal).then(toResults),
);
const dumps = state.status === "loaded" ? state.data.dumps : null;
@@ -159,6 +168,33 @@ export function Search(): React.JSX.Element {
{ isOwner: true, noNewEntries: 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<SearchResults> | 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),