diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 9578b2dc..d848d6cd 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -229,6 +229,7 @@ def _channel_detail_dict( count_live: int, deep_requested: bool, priority: int, + hidden: bool, tag_ids: list[int], ) -> dict: return { @@ -262,6 +263,9 @@ def _channel_detail_dict( # This user's per-subscription feed priority (0 when not subscribed) so the channel page can # offer the same priority stepper as the manager. Editable only while subscribed. "priority": priority, + # This user's per-subscription "hide from feed" flag (False when not subscribed), so the + # channel page can offer the same Unhide toggle the manager has. Editable only while subscribed. + "hidden": hidden, "backfill_done": channel.backfill_done, "from_explore": channel.from_explore, "details_synced": channel.details_synced_at is not None, @@ -344,6 +348,7 @@ def channel_detail( count_live=count_live, deep_requested=bool(sub.deep_requested) if sub else False, priority=int(sub.priority) if sub else 0, + hidden=bool(sub.hidden) if sub else False, tag_ids=tag_ids, ) diff --git a/backend/tests/test_channel_backfill.py b/backend/tests/test_channel_backfill.py index 91bd4447..d1244641 100644 --- a/backend/tests/test_channel_backfill.py +++ b/backend/tests/test_channel_backfill.py @@ -55,6 +55,49 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch): assert body["count_normal"] == 2 +@pytest.mark.parametrize("hidden", [True, False]) +def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden): + # S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer + # the same Unhide toggle the manager has (the flag was previously omitted from the detail dict). + ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB) + db.add(ch) + me = User(email="hid@example.com") + db.add(me) + db.flush() + db.add(Subscription(user_id=me.id, channel_id=ch.id, hidden=hidden)) + db.commit() + + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: me + try: + resp = TestClient(app).get(f"/api/channels/{ch.id}") + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + assert resp.json()["hidden"] is hidden + + +def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch): + # No subscription row → the flag can't be True; it must serialize as False, not raise. + ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB) + me = User(email="hidns@example.com") + db.add_all([ch, me]) + db.commit() + + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: me + try: + resp = TestClient(app).get(f"/api/channels/{ch.id}") + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + assert resp.json()["hidden"] is False + + def _setup_channel_and_sub(db, *, email, is_demo=False): ch = Channel( id="UC_wire", diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 0595c20b..80ebce67 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -12,6 +12,8 @@ import { Ban, Check, ExternalLink, + Eye, + EyeOff, History, Loader2, Plus, @@ -137,6 +139,17 @@ export default function ChannelPage({ // a caller toast here would just double it. }); + // "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}). + // Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore. + const toggleHidden = useMutation({ + mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.channel(channelId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.channels() }); + }, + }); + const subscribe = useMutation({ mutationFn: () => api.subscribeChannel(channelId), onSuccess: () => { @@ -470,6 +483,24 @@ export default function ChannelPage({ )} + {/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel + (mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos + from the feed. EyeOff = currently hidden (click to unhide). */} + {ch?.subscribed && !me.is_demo && ( + + )} {!me.is_demo && !ch?.blocked && (ch?.subscribed ? ( diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 030f40ec..f580d7a6 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -9,8 +9,8 @@ import { useChipOverflowFit } from "../lib/useChipOverflowFit"; // The channel tag surface, shared by the channel manager (table + cards) and the channel detail // page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell: -// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags -// (which grows the chip row) never moves it, and the editor popover doesn't drift. +// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags +// (which grows the chip row to its right) never moves it, and the editor popover doesn't drift. // 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and // collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never // wraps to a 2nd line, so it can't widen its table column and reflow every other row. @@ -281,9 +281,9 @@ function TagEditorMenu({ ); } -/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals - * all), followed by a stable "+" trigger that opens the shared editor. Fills its container width, - * so a table cell bounds it via the column width and a card bounds it via the card. */ +/** The full tag control for a channel: a stable leading "+" trigger (opens the shared editor), + * followed by a width-bounded chip row with "+N" overflow (hover reveals all). Fills its container + * width, so a table cell bounds it via the column width and a card bounds it via the card. */ export function ChannelTagsControl({ userTags, attachedIds, @@ -376,6 +376,25 @@ export function ChannelTagsControl({
+ {/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added + (they grow to its right), so its editor popover can't drift. Compact icon in the width- + bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */} + + {/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged channels only, and accepting one removes the channel from the suggestion set — so a channel @@ -437,17 +456,6 @@ export function ChannelTagsControl({ )} - - {/* Hover: reveal every attached tag when some are collapsed into "+N". */}