v4: the followed feed's two views are pages — "From people" and "From playlists" on a feed's "follow" tab were a state of the page, which a reload, Back or a shared link lost, while a profile's same split is /@name/follow/playlists. They are now /follow and /follow/playlists, and /music/follow/playlists in a category, feedPath taking the view as userPath does (FOLLOW_VIEWS); picking a category keeps it. Their dumps come from GET /api/dumps?follow=users|playlists, signed in, which the feed's word for it names, instead of GET /api/me/feed?from=; an unknown category is a 404 there, as on the rest of the feed, rather than an empty list.

A feed's tab, as a profile's, a search's or the community page's, now takes the place of the current history entry, since it is a view of the same page; picking a category, another page, still adds one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 22:09:12 +00:00
parent 53ceb6bcbc
commit 6dfba07341
13 changed files with 244 additions and 147 deletions

View File

@@ -3,7 +3,11 @@ import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { useLocation } from "react-router";
import { Index } from "../../pages/Index.tsx";
import { FEED_TABS, type FeedTab } from "../../../shared/paths.ts";
import {
FEED_TABS,
type FeedTab,
type FollowView,
} from "../../../shared/paths.ts";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
@@ -22,14 +26,16 @@ const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
/** Reads a feed's path as `App`'s routes do. */
function Scoped() {
const { pathname } = useLocation();
const [first, second] = pathname.split("/").filter(Boolean);
const isTab = (FEED_TABS as readonly string[]).includes(first ?? "");
const categorySlug = first && !isTab ? first : undefined;
const segments = pathname.split("/").filter(Boolean);
const isTab = (FEED_TABS as readonly string[]).includes(segments[0] ?? "");
const categorySlug = isTab ? undefined : segments[0];
const [feedTab, followView] = segments.slice(isTab ? 0 : 1);
return (
<Index
key={categorySlug ?? ""}
categorySlug={categorySlug}
feedTab={(isTab ? first : second) as FeedTab | undefined}
feedTab={feedTab as FeedTab | undefined}
followView={followView as FollowView | undefined}
/>
);
}
@@ -96,7 +102,7 @@ describe("Index feeds", () => {
});
test('a default tab stored as v3\'s "followed" is "follow" now', async () => {
route("GET /api/me/feed", () => page([]));
route("GET /api/dumps", () => page([]));
localStorage.setItem("default-feed-tab", "followed");
setup("/", { session: makeSession() });
expect(
@@ -273,13 +279,16 @@ describe("Index feeds", () => {
});
describe("Followed feed", () => {
test("from people and from playlists", async () => {
const followCalls = () =>
calls("GET /api/dumps").filter((r) => r.url.searchParams.has("follow"));
test("from people at /follow, from playlists at /follow/playlists", async () => {
const session = makeSession();
route(
"GET /api/me/feed",
"GET /api/dumps",
(req) =>
page(
req.url.searchParams.get("from") === "users"
req.url.searchParams.get("follow") === "users"
? [makeDump({ title: "By a friend" })]
: [],
),
@@ -287,20 +296,25 @@ describe("Followed feed", () => {
const { user } = setup("/follow", { session });
await screen.findByText("By a friend");
await user.click(screen.getByRole("button", { name: "From playlists" }));
expect(currentPath()).toBe("/follow/playlists");
expect(
screen.getByText("Follow some public playlists to see their dumps here."),
).toBeInTheDocument();
const [feed] = calls("GET /api/me/feed");
const [feed] = followCalls();
expect(feed.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
await user.click(screen.getByRole("button", { name: "From people" }));
expect(currentPath()).toBe("/follow");
});
test("a category narrows it", async () => {
route("GET /api/me/feed", () => page([]));
setup("/music/follow", { session: makeSession() });
await screen.findByText("Follow some users to see their dumps here.");
test("its view is in the path, a category's too", async () => {
route("GET /api/dumps", () => page([]));
setup("/music/follow/playlists", { session: makeSession() });
await screen.findByText(
"Follow some public playlists to see their dumps here.",
);
expect(
calls("GET /api/me/feed").map((r) => [
r.url.searchParams.get("from"),
followCalls().map((r) => [
r.url.searchParams.get("follow"),
r.url.searchParams.get("category"),
]).sort(),
).toEqual([["playlists", "music"], ["users", "music"]]);
@@ -308,9 +322,9 @@ describe("Followed feed", () => {
test("a server error reads as one, not as a lost connection", async () => {
route(
"GET /api/me/feed",
"GET /api/dumps",
(req) =>
req.url.searchParams.get("from") === "users"
req.url.searchParams.get("follow") === "users"
? fail("Internal error", 500)
: page([]),
);