feat(feed): a card's menu filters the feed by its channel, and back
Under "Open the channel" a video card's menu (the right-click on its channel name included) adds the channel to the feed's channel filter, or, when it is already in, takes it out. Channels OR together, so it widens a filter that names others, as "Show in feed" does. Not on a channel page, whose view is that channel already, nor in a live YouTube search or on Home.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onResetState, onOpen }} />
|
||||
<VideoMenu
|
||||
menu={menu}
|
||||
handlers={{ onState, onToggleSave, onResetState, onOpen, channelFilter }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: <Tv className={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: <FilterX className={icon} />,
|
||||
onSelect: () =>
|
||||
channelFilter.set(channelFilter.ids.filter((id) => id !== v.channel_id)),
|
||||
}
|
||||
: {
|
||||
id: "channel-filter",
|
||||
label: t("card.menu.channelFilterAdd"),
|
||||
icon: <Filter className={icon} />,
|
||||
onSelect: () => channelFilter.set([...channelFilter.ids, v.channel_id]),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "watched",
|
||||
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user