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:
2026-07-16 00:54:22 +02:00
parent 82e351bc62
commit 0d10e0f0a5
11 changed files with 88 additions and 66 deletions
+1 -2
View File
@@ -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;
+43 -29
View File
@@ -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");
}
+13 -3
View File
@@ -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)}