diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 91e1ec44..09b4e057 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -878,6 +878,15 @@ export default function Feed({ onToggleSave={onToggleSave} onResetState={onResetState} onOpen={openVideo} + // A channel page's view is the channel already: no row there to narrow it to one. + channelFilter={ + channelScoped + ? undefined + : { + ids: filters.channelIds, + set: (channelIds) => setFilters({ ...filters, channelIds }), + } + } // A failed next page stops the read-ahead: retry is off, and re-running on every settle would // re-request it once a round-trip for as long as the end is in view. The row // below says so and retries on a click. diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 52c2c63c..7a864913 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -3,6 +3,7 @@ import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import type { NEXT_PAGE } from "../lib/nextPage"; import VideoCard from "./VideoCard"; import { VideoMenu } from "./VideoMenu"; +import type { VideoActionHandlers } from "./useVideoActions"; import { useContextMenu } from "./ui/useContextMenu"; import VirtualGrid from "./ui/VirtualGrid"; @@ -28,6 +29,8 @@ export interface VirtualFeedProps { onToggleSave: (id: string, saved: boolean) => void; onResetState: (id: string) => void; onOpen: (v: Video, startAt?: number | null) => void; + /** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */ + channelFilter?: VideoActionHandlers["channelFilter"]; hasNextPage: boolean; isFetching: boolean; fetchNextPage: (options: typeof NEXT_PAGE) => unknown; @@ -43,6 +46,7 @@ export default function VirtualFeed({ onToggleSave, onResetState, onOpen, + channelFilter, hasNextPage, isFetching, fetchNextPage, @@ -86,7 +90,10 @@ export default function VirtualFeed({ /> )} /> - + ); } diff --git a/frontend/src/components/useVideoActions.dom.test.tsx b/frontend/src/components/useVideoActions.dom.test.tsx index 1045a934..016038bc 100644 --- a/frontend/src/components/useVideoActions.dom.test.tsx +++ b/frontend/src/components/useVideoActions.dom.test.tsx @@ -55,3 +55,52 @@ describe("the video menu's download row", () => { expect(none().note).toBeUndefined(); }); }); + +// The feed's channel filter from a card (owner): under "Open the channel", a row that adds the card's +// channel to the filter, or takes it out again when it is already in. +describe("the video menu's channel-filter row", () => { + const menu = (ids: string[] | null) => { + vi.spyOn(api, "downloadIndex").mockResolvedValue({}); + const set = vi.fn(); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook( + () => + useVideoActions( + { + onState: vi.fn(), + onToggleSave: vi.fn(), + channelFilter: ids ? { ids, set } : undefined, + }, + true, + ), + { wrapper }, + ); + const items = result.current.items({ ...video, channel_id: "UCa" }, null); + return { items, set }; + }; + + it("adds the channel to a filter that names others, right under Open the channel", () => { + const { items, set } = menu(["UCz"]); + const at = items.findIndex((i) => i.id === "channel"); + const row = items[at + 1]!; + expect(row.id).toBe("channel-filter"); + expect(row.label).toBe("card.menu.channelFilterAdd"); + row.onSelect?.(); + expect(set).toHaveBeenCalledWith(["UCz", "UCa"]); + }); + + it("takes it out again when it is in", () => { + const { items, set } = menu(["UCz", "UCa"]); + const row = items.find((i) => i.id === "channel-filter")!; + expect(row.label).toBe("card.menu.channelFilterRemove"); + row.onSelect?.(); + expect(set).toHaveBeenCalledWith(["UCz"]); + }); + + it("is absent where the host gives no filter (a channel page, Home, a live search)", () => { + expect(menu(null).items.some((i) => i.id === "channel-filter")).toBe(false); + }); +}); diff --git a/frontend/src/components/useVideoActions.tsx b/frontend/src/components/useVideoActions.tsx index a213515a..e5db796e 100644 --- a/frontend/src/components/useVideoActions.tsx +++ b/frontend/src/components/useVideoActions.tsx @@ -8,6 +8,8 @@ import { Download, Eye, EyeOff, + Filter, + FilterX, Info, ListPlus, Play, @@ -39,6 +41,9 @@ export interface VideoActionHandlers { onResetState?: (id: string) => void; /** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */ onOpen?: (v: Video, startAt?: number | null) => void; + /** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel + * page (the view is that channel already), in a live YouTube search and on Home. */ + channelFilter?: { ids: string[]; set: (ids: string[]) => void }; } const icon = "w-4 h-4"; @@ -65,7 +70,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) { anchor: Element | null, { pin: withPin = true }: { pin?: boolean } = {}, ): ActionMenuItem[] => { - const { onState, onToggleSave, onResetState, onOpen } = handlers; + const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers; const watched = v.status === "watched"; const hidden = v.status === "hidden"; const inProgress = v.position_seconds > 0 && !watched; @@ -112,6 +117,26 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) { icon: , onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")), }, + // The channel's videos in this feed, or the channel out of the filter again: channels OR + // together, so adding one widens a filter that already names others (as "Show in feed" does). + ...(channelFilter + ? [ + channelFilter.ids.includes(v.channel_id) + ? { + id: "channel-filter", + label: t("card.menu.channelFilterRemove"), + icon: , + onSelect: () => + channelFilter.set(channelFilter.ids.filter((id) => id !== v.channel_id)), + } + : { + id: "channel-filter", + label: t("card.menu.channelFilterAdd"), + icon: , + onSelect: () => channelFilter.set([...channelFilter.ids, v.channel_id]), + }, + ] + : []), { id: "watched", label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"), diff --git a/frontend/src/i18n/locales/en/card.json b/frontend/src/i18n/locales/en/card.json index 93b84128..2513b3c9 100644 --- a/frontend/src/i18n/locales/en/card.json +++ b/frontend/src/i18n/locales/en/card.json @@ -24,6 +24,8 @@ "unmarkWatched": "Mark unwatched", "unsave": "Remove from saved", "more": "More actions", - "openChannel": "Open the channel" + "openChannel": "Open the channel", + "channelFilterAdd": "Filter by this channel", + "channelFilterRemove": "Remove the channel from the filter" } } diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json index 4b819df6..f8bd0835 100644 --- a/frontend/src/i18n/locales/hu/card.json +++ b/frontend/src/i18n/locales/hu/card.json @@ -24,6 +24,8 @@ "unmarkWatched": "Megnézés visszavonása", "unsave": "Eltávolítás a mentettek közül", "more": "További műveletek", - "openChannel": "Csatorna megnyitása" + "openChannel": "Csatorna megnyitása", + "channelFilterAdd": "Szűrés erre a csatornára", + "channelFilterRemove": "A csatorna ki a szűrőből" } }