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}
|
onToggleSave={onToggleSave}
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={openVideo}
|
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
|
// 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
|
// 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.
|
// 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 type { NEXT_PAGE } from "../lib/nextPage";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
import { VideoMenu } from "./VideoMenu";
|
import { VideoMenu } from "./VideoMenu";
|
||||||
|
import type { VideoActionHandlers } from "./useVideoActions";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import VirtualGrid from "./ui/VirtualGrid";
|
import VirtualGrid from "./ui/VirtualGrid";
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ export interface VirtualFeedProps {
|
|||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onResetState: (id: string) => void;
|
onResetState: (id: string) => void;
|
||||||
onOpen: (v: Video, startAt?: number | null) => 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;
|
hasNextPage: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
|
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
|
||||||
@@ -43,6 +46,7 @@ export default function VirtualFeed({
|
|||||||
onToggleSave,
|
onToggleSave,
|
||||||
onResetState,
|
onResetState,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
channelFilter,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetching,
|
isFetching,
|
||||||
fetchNextPage,
|
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();
|
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,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Filter,
|
||||||
|
FilterX,
|
||||||
Info,
|
Info,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
Play,
|
Play,
|
||||||
@@ -39,6 +41,9 @@ export interface VideoActionHandlers {
|
|||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
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";
|
const icon = "w-4 h-4";
|
||||||
@@ -65,7 +70,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
anchor: Element | null,
|
anchor: Element | null,
|
||||||
{ pin: withPin = true }: { pin?: boolean } = {},
|
{ pin: withPin = true }: { pin?: boolean } = {},
|
||||||
): ActionMenuItem[] => {
|
): ActionMenuItem[] => {
|
||||||
const { onState, onToggleSave, onResetState, onOpen } = handlers;
|
const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers;
|
||||||
const watched = v.status === "watched";
|
const watched = v.status === "watched";
|
||||||
const hidden = v.status === "hidden";
|
const hidden = v.status === "hidden";
|
||||||
const inProgress = v.position_seconds > 0 && !watched;
|
const inProgress = v.position_seconds > 0 && !watched;
|
||||||
@@ -112,6 +117,26 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
icon: <Tv className={icon} />,
|
icon: <Tv className={icon} />,
|
||||||
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
|
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",
|
id: "watched",
|
||||||
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
|
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"unmarkWatched": "Mark unwatched",
|
"unmarkWatched": "Mark unwatched",
|
||||||
"unsave": "Remove from saved",
|
"unsave": "Remove from saved",
|
||||||
"more": "More actions",
|
"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",
|
"unmarkWatched": "Megnézés visszavonása",
|
||||||
"unsave": "Eltávolítás a mentettek közül",
|
"unsave": "Eltávolítás a mentettek közül",
|
||||||
"more": "További műveletek",
|
"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