feat(filters): filter the feed by several channels at once (OR)
Pick any number of channels; they OR together and every other filter applies across the lot — "the last week's unwatched from these three". `channelId`/`channelName` are replaced outright by `channelIds: string[]`, no legacy reader (standing rule: no back-compat unless asked — a saved view written against the old field simply loses its channel filter). The wire and the share URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via `Video.channel_id.in_()`; the count endpoint shares the same params object. Two things the migration exposed: - SavedViewsWidget fed DB blobs straight into serializers that now index an array — the FeedFilters type says nothing about what an older build wrote, so the blob is coerced where it enters. It white-screened the app before this. - Chips carried channelName to label themselves. Without it they resolve names from the channel cache, so they now fall back to the id, not a shared "This channel" that would render N identical chips while the cache loads.
This commit is contained in:
@@ -89,6 +89,7 @@ const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
@@ -846,11 +847,7 @@ export default function App() {
|
||||
onShowInFeed={() => {
|
||||
// Keep the rest of the feed's filters — the point is "this channel, the way I
|
||||
// normally read": unwatched, my tags, my date window.
|
||||
setFilters({
|
||||
...filters,
|
||||
channelId: channelView.id,
|
||||
channelName: channelView.name,
|
||||
});
|
||||
setFilters({ ...filters, channelIds: [channelView.id] });
|
||||
closeChannel();
|
||||
setPage("feed");
|
||||
}}
|
||||
@@ -905,7 +902,7 @@ export default function App() {
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
onViewChannel={(id, name) => openChannel(id, name)}
|
||||
onFilterByTag={(tagId, name) => {
|
||||
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
|
||||
setFilters({ ...filters, tags: [tagId], channelIds: [] });
|
||||
setPage("feed");
|
||||
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
||||
}}
|
||||
|
||||
@@ -177,8 +177,7 @@ export default function ChannelPage({
|
||||
includeShorts: false,
|
||||
includeLive: true,
|
||||
show: "all",
|
||||
channelId,
|
||||
channelName: initialName,
|
||||
channelIds: [channelId],
|
||||
}));
|
||||
|
||||
const name = ch?.title ?? initialName ?? channelId;
|
||||
|
||||
@@ -6,8 +6,9 @@ import { api, type FeedFilters } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// Pick one channel to narrow the feed to. Until this existed, `filters.channelId` was settable only
|
||||
// via a shared "?channel=" link — the panel had a remove button for a filter nothing could apply.
|
||||
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
||||
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
|
||||
// the panel had a remove button for something nothing could apply.
|
||||
//
|
||||
// It owns its channel query rather than letting the Sidebar hold it: the Sidebar is mounted for the
|
||||
// whole feed page, but SidePanel only renders its children once the panel is open, so keeping the
|
||||
@@ -29,28 +30,35 @@ export default function ChannelPicker({
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (filters.channelId) {
|
||||
const name =
|
||||
filters.channelName ??
|
||||
channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ??
|
||||
(channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.thisChannel"));
|
||||
return (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, channelId: undefined, channelName: undefined })}
|
||||
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
|
||||
>
|
||||
<span className="truncate">{name}</span>
|
||||
<X className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = channelsQuery.data ?? [];
|
||||
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
||||
const picked = filters.channelIds;
|
||||
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
|
||||
const toggle = (id: string) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
|
||||
{picked.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-2">
|
||||
{picked.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => toggle(id)}
|
||||
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
||||
className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-sm hover:opacity-90 transition"
|
||||
>
|
||||
<span className="truncate">{nameOf(id)}</span>
|
||||
<X className="w-3 h-3 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -74,17 +82,23 @@ export default function ChannelPicker({
|
||||
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
||||
</div>
|
||||
) : (
|
||||
matches.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setFilters({ ...filters, channelId: c.id, channelName: c.title ?? undefined })}
|
||||
// shrink-0 is load-bearing: these are flex children in a max-height box, so without them
|
||||
// they squash to ~12px each and clip their own text instead of the box scrolling.
|
||||
className="w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card truncate transition"
|
||||
>
|
||||
{c.title ?? c.id}
|
||||
</button>
|
||||
))
|
||||
matches.map((c) => {
|
||||
const on = picked.includes(c.id);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => toggle(c.id)}
|
||||
aria-pressed={on}
|
||||
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
|
||||
// them they squash to ~12px each and clip their own text instead of the box scrolling.
|
||||
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
|
||||
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{c.title ?? c.id}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -86,8 +86,7 @@ export default function NotificationsPanel({
|
||||
setFilters({
|
||||
...filters,
|
||||
show: meta.kind === "video-hidden" ? "hidden" : "watched",
|
||||
channelId: meta.channelId || undefined,
|
||||
channelName: meta.channelName || undefined,
|
||||
channelIds: meta.channelId ? [meta.channelId] : [],
|
||||
});
|
||||
setPage("feed");
|
||||
}
|
||||
|
||||
@@ -26,7 +26,17 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
// Compact, order-independent signature of a filter set (reuses the share serializer, which
|
||||
// emits only non-default values and ignores the transient shuffle seed). Used to highlight the
|
||||
// saved view that matches the feed's current filters.
|
||||
const keyOf = (f: FeedFilters) => filtersToParams(f).toString();
|
||||
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
|
||||
|
||||
// A saved view's `filters` is a JSON blob straight from the DB — the FeedFilters type says nothing
|
||||
// about what an older build actually wrote there, so coerce the array fields before the blob reaches
|
||||
// app state or a serializer. Deliberately NOT a migration: a view saved against a dropped field just
|
||||
// loses that filter.
|
||||
const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||
...raw,
|
||||
tags: raw.tags ?? [],
|
||||
channelIds: raw.channelIds ?? [],
|
||||
});
|
||||
|
||||
function syncDefaultMirror(views: SavedView[]): void {
|
||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||
@@ -99,7 +109,7 @@ export default function SavedViewsWidget({
|
||||
|
||||
async function share(v: SavedView) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl(v.filters));
|
||||
await navigator.clipboard.writeText(shareUrl(asFilters(v.filters)));
|
||||
notify({ level: "success", message: t("views.shareCopied") });
|
||||
} catch {
|
||||
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
||||
@@ -138,7 +148,7 @@ export default function SavedViewsWidget({
|
||||
view={v}
|
||||
active={keyOf(v.filters) === currentKey}
|
||||
renaming={renameId === v.id}
|
||||
onApply={() => onApply(v.filters)}
|
||||
onApply={() => onApply(asFilters(v.filters))}
|
||||
onStartRename={() => setRenameId(v.id)}
|
||||
onRename={(name) => rename(v.id, name)}
|
||||
onCancelRename={() => setRenameId(null)}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"editHint": "Drag to reorder · eye to show/hide",
|
||||
"channel": "Channel",
|
||||
"loading": "Loading…",
|
||||
"thisChannel": "This channel",
|
||||
"channelCount": "{{count}} channel",
|
||||
"channelCount_other": "{{count}} channels",
|
||||
"dragToReorder": "Drag to reorder",
|
||||
@@ -76,5 +75,6 @@
|
||||
},
|
||||
"channelSearch": "Search channels…",
|
||||
"noChannelMatch": "No channel matches.",
|
||||
"channelOrder": "Priority first, then A–Z"
|
||||
"channelOrder": "Priority first, then A–Z",
|
||||
"channelRemove": "Remove channel: {{name}}"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"editHint": "Húzd az átrendezéshez · szem a megjelenítéshez/elrejtéshez",
|
||||
"channel": "Csatorna",
|
||||
"loading": "Betöltés…",
|
||||
"thisChannel": "Ez a csatorna",
|
||||
"channelCount": "{{count}} csatorna",
|
||||
"channelCount_other": "{{count}} csatorna",
|
||||
"dragToReorder": "Húzd az átrendezéshez",
|
||||
@@ -76,5 +75,6 @@
|
||||
},
|
||||
"channelSearch": "Csatornák keresése…",
|
||||
"noChannelMatch": "Nincs találat.",
|
||||
"channelOrder": "Prioritás szerint, majd A–Z"
|
||||
"channelOrder": "Prioritás szerint, majd A–Z",
|
||||
"channelRemove": "Csatorna eltávolítása: {{name}}"
|
||||
}
|
||||
|
||||
@@ -184,8 +184,8 @@ export interface FeedFilters {
|
||||
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
||||
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
||||
librarySource?: "organic" | "all" | "search" | "plex";
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
|
||||
channelIds: string[];
|
||||
maxAgeDays?: number;
|
||||
minDuration?: number;
|
||||
maxDuration?: number;
|
||||
@@ -432,7 +432,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
||||
p.set("include_shorts", String(f.includeShorts));
|
||||
p.set("include_live", String(f.includeLive));
|
||||
p.set("show", f.show);
|
||||
if (f.channelId) p.set("channel_id", f.channelId);
|
||||
f.channelIds.forEach((id) => p.append("channel_ids", id));
|
||||
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
||||
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
||||
|
||||
@@ -37,7 +37,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||
if (f.includeLive) p.set("live", "1");
|
||||
if (f.tags.length) p.set("tags", f.tags.join(","));
|
||||
if (f.tagMode === "and") p.set("tagmode", "and");
|
||||
if (f.channelId) p.set("channel", f.channelId);
|
||||
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
|
||||
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
||||
if (f.dateFrom) p.set("from", f.dateFrom);
|
||||
if (f.dateTo) p.set("to", f.dateTo);
|
||||
@@ -72,7 +72,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
||||
.map((s) => Number(s))
|
||||
.filter((n) => Number.isInteger(n));
|
||||
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
|
||||
f.channelId = params.get("channel") || undefined;
|
||||
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
|
||||
f.maxAgeDays = num(params.get("maxage"));
|
||||
f.dateFrom = params.get("from") || undefined;
|
||||
f.dateTo = params.get("to") || undefined;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ActiveFilter {
|
||||
export const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
channelIds: [],
|
||||
sort: "newest",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
@@ -48,7 +49,7 @@ export function useActiveFilters(
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["channels"],
|
||||
queryFn: api.channels,
|
||||
enabled: !!filters.channelId && !filters.channelName,
|
||||
enabled: filters.channelIds.length > 0,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
@@ -95,15 +96,16 @@ export function useActiveFilters(
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.channelId) {
|
||||
const name =
|
||||
filters.channelName ??
|
||||
channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ??
|
||||
t("sidebar.thisChannel");
|
||||
// One chip per channel — they OR together, so dropping one shouldn't drop the rest (same shape
|
||||
// as tags).
|
||||
for (const id of filters.channelIds) {
|
||||
out.push({
|
||||
key: "channel",
|
||||
label: name,
|
||||
remove: () => setFilters({ ...filters, channelId: undefined, channelName: undefined }),
|
||||
key: `channel:${id}`,
|
||||
// Fall back to the id, not a generic label: several chips reading "This channel" while
|
||||
// the name cache loads would be indistinguishable, and each ✕ drops a different one.
|
||||
label: channelsQuery.data?.find((c) => c.id === id)?.title ?? id,
|
||||
remove: () =>
|
||||
setFilters({ ...filters, channelIds: filters.channelIds.filter((x) => x !== id) }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user