feat(playlists): say which lists YouTube's API cannot give us
The module shows fewer lists than youtube.com, and without a word about it that read as a Siftlode bug (the owner's ask, 2026-09-03). Owner's pick from the S7 mockups (2026-09-19): one quiet line at the foot of the rail, "Missing a list? Why?", whose Why? opens three facts — YouTube's own Watch later and History are closed to its API (the Watch later list here is Siftlode's own), only lists you own sync, and an account with no YouTube channel has no playlists at all.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The playlists module shows fewer lists than youtube.com, and said nothing about why, so it read as
|
||||
// a Siftlode bug (debt burn A3). One quiet line at the foot of the rail; "Why?" opens the three facts.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
||||
|
||||
// Same floating-ui recipe as ContextMenu.dom.test.tsx: stub the observers, and query with
|
||||
// `hidden: true` — floating-ui never positions in jsdom, so the surface stays visibility:hidden.
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PlaylistLimitsHint", () => {
|
||||
it("keeps the facts behind Why?, and opens them as a named dialog", async () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<PlaylistLimitsHint />
|
||||
</FloatingTree>,
|
||||
);
|
||||
expect(screen.getByText("playlists.limits.hint")).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog", { hidden: true })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "playlists.limits.why" }));
|
||||
const dialog = await screen.findByRole("dialog", { hidden: true });
|
||||
// Read off the attribute: jsdom computes no name for a visibility:hidden element.
|
||||
expect(dialog.getAttribute("aria-label")).toBe("playlists.limits.title");
|
||||
for (const fact of ["closed", "ownOnly", "noChannel"])
|
||||
expect(dialog.textContent).toContain(`playlists.limits.${fact}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
|
||||
// The playlists module shows fewer lists than youtube.com does, and without a word about it that
|
||||
// reads as Siftlode being broken (the owner's ask, 2026-09-03: say it is YouTube's API, up front).
|
||||
// Three facts, behind one quiet line at the foot of the rail — the owner's pick from the S7 mockups
|
||||
// (2026-09-19): always there to find again, never in the way.
|
||||
export default function PlaylistLimitsHint() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "right-end",
|
||||
offset: 8,
|
||||
});
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 px-1 pt-2 text-2xs text-muted">
|
||||
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("playlists.limits.hint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("playlists.limits.why")}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("playlists.limits.title")}
|
||||
className="glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl p-3.5 text-xs leading-relaxed text-fg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<p className="mb-2 font-semibold">{t("playlists.limits.title")}</p>
|
||||
<ul className="list-disc space-y-1.5 pl-4 text-muted">
|
||||
<li>{t("playlists.limits.closed")}</li>
|
||||
<li>{t("playlists.limits.ownOnly")}</li>
|
||||
<li>{t("playlists.limits.noChannel")}</li>
|
||||
</ul>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
@@ -382,6 +383,7 @@ export default function PlaylistsRail({
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
|
||||
<PlaylistLimitsHint />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -118,5 +118,13 @@
|
||||
"viewDiffers": "Temporary view ({{view}}) — the playlist's saved order is unchanged. Save it to keep this order.",
|
||||
"viewMatches": "This view ({{view}}) already matches the playlist's saved order, so there is nothing to save.",
|
||||
"viewFiltered": "Filtered view — {{shown}} of {{total}} shown. The order can't be saved while a filter is on.",
|
||||
"addFailed": "Couldn't add it to the playlist."
|
||||
"addFailed": "Couldn't add it to the playlist.",
|
||||
"limits": {
|
||||
"hint": "Missing a list?",
|
||||
"why": "Why?",
|
||||
"title": "What YouTube lets Siftlode read",
|
||||
"closed": "YouTube's own Watch later and History have been closed to its API since 2016, so no app can read them. The Watch later list here is Siftlode's own.",
|
||||
"ownOnly": "Only playlists you own sync. Lists you saved from other channels are not returned.",
|
||||
"noChannel": "A Google account with no YouTube channel has no playlists at all, as far as the API is concerned."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,13 @@
|
||||
"viewDiffers": "Ideiglenes nézet ({{view}}) — a lista mentett sorrendje változatlan. Mentsd, ha ez maradjon.",
|
||||
"viewMatches": "Ez a nézet ({{view}}) megegyezik a lista mentett sorrendjével, így nincs mit menteni.",
|
||||
"viewFiltered": "Szűrt nézet — {{total}} elemből {{shown}} látszik. Szűrés közben a sorrend nem menthető.",
|
||||
"addFailed": "Nem sikerült hozzáadni a lejátszási listához."
|
||||
"addFailed": "Nem sikerült hozzáadni a lejátszási listához.",
|
||||
"limits": {
|
||||
"hint": "Hiányzik egy lista?",
|
||||
"why": "Miért?",
|
||||
"title": "Amit a YouTube kiad a Siftlode-nak",
|
||||
"closed": "A YouTube saját „Megnézem később” listája és az Előzmények 2016 óta zárva vannak az API előtt, így egyik alkalmazás sem olvashatja őket. Az itteni „Megnézem később” lista a Siftlode saját listája.",
|
||||
"ownOnly": "Csak a saját listáid szinkronizálódnak. A más csatornáktól elmentett listákat az API nem adja vissza.",
|
||||
"noChannel": "Ha a Google-fiókodhoz nem tartozik YouTube-csatorna, az API szerint egy listád sincs."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user