feat(nav): the live YouTube search and Home's budget are named in the address
A YouTube search is `?yt=<term>` (+ `ytn` for a non-default count), so a reload, a link or Back/Forward reopens it. It runs by itself only where it costs no quota: the free page scrape, a search asked for in this document, or results still cached. An API search reopened any other way shows its term and waits for "Search YouTube again". Home's time budget is `hb`; the question's draw stays on the history entry. An e2e spec opens every page's bookmark in a fresh browser context; CLAUDE.md and docs/development.md name the module URL codec as the place for view state.
This commit is contained in:
@@ -40,6 +40,8 @@ import {
|
||||
} from "../../features/video/videoWrites";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { YT_COUNTS } from "../../lib/ytSearchUrlState";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import {
|
||||
markedRows,
|
||||
@@ -148,9 +150,6 @@ const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
|
||||
return next;
|
||||
};
|
||||
|
||||
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
||||
const YT_COUNTS = [20, 40, 60, 100] as const;
|
||||
|
||||
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
|
||||
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
|
||||
const SEARCH_FINAL_REASONS = new Set([
|
||||
@@ -221,7 +220,8 @@ export default function Feed({
|
||||
const qc = useQueryClient();
|
||||
const videoMarks = useVideoMarks();
|
||||
// Live search turned off on this instance: the empty feed offers no search that would be refused.
|
||||
const searchOn = useMe().search_enabled;
|
||||
const me = useMe();
|
||||
const searchOn = me.search_enabled;
|
||||
|
||||
// What changed state while the player was open, and the writes still in flight: closing it
|
||||
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
|
||||
@@ -263,8 +263,16 @@ export default function Feed({
|
||||
|
||||
const ytActive = !!ytSearch;
|
||||
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||
// the free scrape source pages through continuations until this many are collected).
|
||||
const [ytCount, setYtCount] = useState(20);
|
||||
// the free scrape source pages through continuations until this many are collected). Named in the
|
||||
// address with the term, so it is the navigation's, like the term.
|
||||
const { ytCount, ytSearchAsked } = useNavigation();
|
||||
const { setYtCount, askYtSearch } = useNavigationActions();
|
||||
// A search runs by itself only where nobody's reload or link can spend quota with it: the free
|
||||
// page scrape, or a search asked for in this document. An API search reopened by a reload, a link
|
||||
// or Back waits for "Search YouTube again" — unless its results are still cached, which a disabled
|
||||
// query shows all the same. Turned off on this instance it runs too: the server refuses it before
|
||||
// any quota, and its refusal is what says so (no press offered that could only be refused).
|
||||
const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn);
|
||||
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
|
||||
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
|
||||
// ranking, which is what the view always showed before it had a control.
|
||||
@@ -298,23 +306,11 @@ export default function Feed({
|
||||
stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: ytActive,
|
||||
enabled: ytRuns,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
|
||||
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
|
||||
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
|
||||
useEffect(() => {
|
||||
if (!ytActive) return;
|
||||
const st = window.history.state;
|
||||
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
|
||||
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
|
||||
window.history.replaceState({ ...st, _ytScrape: true }, "");
|
||||
}
|
||||
}, [ytActive, ytSearch, ytQuery.data]);
|
||||
|
||||
// Switching to the relevance sort when a search starts happens atomically in the header's
|
||||
// input onChange (race-free with the query update). Here we only handle the reverse: when
|
||||
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
|
||||
@@ -556,7 +552,8 @@ export default function Feed({
|
||||
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
|
||||
// The backend reports which source served the search; scrape spends no search quota, so we
|
||||
// drop the quota warning + "uses quota" wording in that mode.
|
||||
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
|
||||
// Before an answer (a search waiting for its press), the instance's own source says it.
|
||||
const zeroQuota = (ytQuery.data?.pages?.[0]?.source ?? me.search_source) === "scrape";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -638,7 +635,16 @@ export default function Feed({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ytQuery.isLoading ? (
|
||||
{!ytRuns && ytQuery.data === undefined ? (
|
||||
// Reopened, not asked for: the term is on screen, the search waits for a press.
|
||||
<div className="py-16 text-center max-w-md mx-auto">
|
||||
<p className="font-semibold">{t("feed.yt.notRunTitle")}</p>
|
||||
<p className="mt-1 text-sm text-muted">{t("feed.yt.notRunBody")}</p>
|
||||
<Button className="mt-4" icon={<YouTubeIcon />} onClick={askYtSearch}>
|
||||
{t("feed.yt.runAgain")}
|
||||
</Button>
|
||||
</div>
|
||||
) : ytQuery.isLoading ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
||||
) : ytError && !ytItems.length ? (
|
||||
// A first page that failed is said as the error it is, with Retry where trying again can
|
||||
|
||||
Reference in New Issue
Block a user