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
+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)}