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

@@ -5,8 +5,9 @@
* more segment, and a default view the bare path, with no redirect.
*
* - `/` is the feed of every dump on the viewer's default tab, and `/hot`,
* `/new`, `/journal` and `/follow` its tabs (`Index`). `/?share_url=` opens
* the create form on a shared link.
* `/new`, `/journal` and `/follow` its tabs (`Index`), `/follow/playlists`
* the dumps of the playlists followed. `/?share_url=` opens the create form
* on a shared link.
* - `/search` and `/search/:searchTab` are the search results (`Search`).
* - `/register` and `/login` sign up and sign in (`UserRegister`,
* `UserLogin`), signed out only, `/login?next=` coming back to a page.
@@ -28,7 +29,7 @@
* signed in only), and their playlists at `playlists/:slug`
* (`PlaylistDetail`).
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
* (`Index`).
* (`Index`), with its `/follow/playlists` too.
* - Any other path is `NotFound`.
*
* The pages not marked are open to anyone. Every page is loaded with its
@@ -49,7 +50,12 @@ import {
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
import { FEED_TABS, type FeedTab } from "../shared/paths.ts";
import {
FEED_TABS,
type FeedTab,
FOLLOW_VIEWS,
type FollowView,
} from "../shared/paths.ts";
import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
@@ -132,18 +138,21 @@ const NotFound = lazy(() =>
/**
* Renders a feed, of every dump or of `categorySlug`'s, on the tab the path's
* segment after the scope names, or the viewer's default one on the bare
* path. Every feed path renders it at the same place in the tree, and the key
* keeps `Index` mounted on a change of tab, which sorts on the client, and
* remounts it on a change of category, so that it refetches.
* path, and the `follow` tab on the view the next segment names. Every feed
* path renders it at the same place in the tree, and the key keeps `Index`
* mounted on a change of tab, which sorts on the client, and remounts it on a
* change of category, so that it refetches.
*/
function Feed({ categorySlug }: { categorySlug?: string }) {
const { pathname } = useLocation();
const segments = pathname.split("/").filter(Boolean);
const rest = segments.slice(categorySlug ? 1 : 0);
const feedTab = rest[0];
const [feedTab, view, ...more] = segments.slice(categorySlug ? 1 : 0);
if (
rest.length > 1 ||
(feedTab && !(FEED_TABS as readonly string[]).includes(feedTab))
more.length > 0 ||
(feedTab && !(FEED_TABS as readonly string[]).includes(feedTab)) ||
(view !== undefined &&
(feedTab !== "follow" || view === FOLLOW_VIEWS[0] ||
!(FOLLOW_VIEWS as readonly string[]).includes(view)))
) {
return <NotFound />;
}
@@ -152,6 +161,7 @@ function Feed({ categorySlug }: { categorySlug?: string }) {
key={categorySlug ?? ""}
categorySlug={categorySlug}
feedTab={feedTab as FeedTab | undefined}
followView={view as FollowView | undefined}
/>
);
}

View File

@@ -9,7 +9,12 @@ import { useNavigate } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
import { feedPath, type FeedTab } from "../../../shared/paths.ts";
import {
feedPath,
type FeedTab,
FOLLOW_VIEWS,
type FollowView,
} from "../../../shared/paths.ts";
import { TabBar } from "../shared/TabBar.tsx";
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
@@ -19,22 +24,34 @@ export interface FeedTabBarProps {
categorySlug?: string;
/** The tab shown, the viewer's default one on a bare path. */
tab: FeedTab;
/** The view of the "follow" tab, which picking a category keeps. */
followView?: FollowView;
}
/**
* Renders the "hot", "new" and "journal" tabs, and "followed" for a signed-in
* user. A tab leads to the same scope: `/new` for every dump, `/music/new` for
* a category, and the viewer's default tab to the bare `/` or `/music`.
* Picking a category keeps the tab, from `/music/new` to `/cinema/new`.
* a category, and the viewer's default tab to the bare `/` or `/music`. A tab
* is a view of the same page, so it takes the place of the current history
* entry, while picking a category, another page, adds one and keeps the tab
* and its view, from `/music/follow/playlists` to `/cinema/follow/playlists`.
* `Index` renders it twice, in the header and below it, and CSS shows one by
* screen width.
*/
export function FeedTabBar(
{ categorySlug, tab }: FeedTabBarProps,
{ categorySlug, tab, followView }: FeedTabBarProps,
): React.JSX.Element {
const { user } = useAuth();
const navigate = useNavigate();
const defaultTab = useViewerDefaultFeedTab();
// The default tab is the bare path, but a view of it other than its default
// needs the tab's segment before its own.
const pathOf = (slug: string | undefined, t: FeedTab, view?: FollowView) =>
feedPath(
slug,
t === defaultTab && (!view || view === FOLLOW_VIEWS[0]) ? undefined : t,
view,
);
const tabs = [
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
@@ -49,14 +66,12 @@ export function FeedTabBar(
<TabBar
tabs={tabs}
activeTab={tab}
onChange={(t) =>
navigate(feedPath(categorySlug, t === defaultTab ? undefined : t))}
onChange={(t) => navigate(pathOf(categorySlug, t), { replace: true })}
leading={
<CategorySwitcher
kind="dump"
categorySlug={categorySlug}
onSelect={(slug) =>
navigate(feedPath(slug, tab === defaultTab ? undefined : tab))}
onSelect={(slug) => navigate(pathOf(slug, tab, followView))}
/>
}
/>

View File

@@ -5,7 +5,8 @@
* @module
*/
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { TabBar } from "../shared/TabBar.tsx";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -26,20 +27,20 @@ import {
} from "../../utils/shared/deserialize.ts";
import type { Dump } from "../../../shared/dump.ts";
import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts";
type FollowedSection = "users" | "playlists";
import { feedPath, type FollowView } from "../../../shared/paths.ts";
import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
/**
* Returns one section's feed, from `GET /api/me/feed?from=<section>`, and the
* call that loads its next page. A section cached like the index's feed starts
* loaded and isn't fetched.
* Returns one view's feed, from `GET /api/dumps?follow=<view>`, and the call
* that loads its next page. A view cached like the index's feed starts loaded
* and isn't fetched.
*/
function useFollowedSection(
section: FollowedSection,
function useFollowedView(
section: FollowView,
categorySlug: string | undefined,
): { state: DumpFeedState; loadMore: () => void } {
const { user, authFetch } = useAuth();
const url = `${API_URL}/api/me/feed?from=${section}${
const url = `${API_URL}/api/dumps?follow=${section}${
categorySlug ? `&category=${encodeURIComponent(categorySlug)}` : ""
}`;
@@ -108,22 +109,26 @@ function FollowedSubFeed(
* signed-in user only. Unlike the other tabs, it loads its own dumps.
*/
export interface FollowedFeedProps {
/** Scopes both sections to a category. */
/** Scopes both views to a category. */
categorySlug?: string;
/** The view the path names: `/follow`, or `/follow/playlists`. */
view: FollowView;
}
/**
* Renders the "followed" tab in two sections, "from people" and "from
* playlists", from `GET /api/me/feed?from=users` and
* `GET /api/me/feed?from=playlists`. Both load at once, and each keeps its own
* pages, cached like the index's feed.
* Renders the "follow" tab in two views, "from people" at `/follow` and "from
* playlists" at `/follow/playlists`, from `GET /api/dumps?follow=users` and
* `GET /api/dumps?follow=playlists`. Both load at once, so switching is
* instant, and each keeps its own pages, cached like the index's feed.
* Switching takes the place of the current history entry, as a tab does.
*/
export function FollowedFeed(
{ categorySlug }: FollowedFeedProps,
{ categorySlug, view }: FollowedFeedProps,
): React.JSX.Element {
const users = useFollowedSection("users", categorySlug);
const playlists = useFollowedSection("playlists", categorySlug);
const [section, setSection] = useState<FollowedSection>("users");
const users = useFollowedView("users", categorySlug);
const playlists = useFollowedView("playlists", categorySlug);
const navigate = useNavigate();
const defaultTab = useViewerDefaultFeedTab();
return (
<div className="followed-feed">
@@ -132,12 +137,20 @@ export function FollowedFeed(
{ key: "users", label: <Trans>From people</Trans> },
{ key: "playlists", label: <Trans>From playlists</Trans> },
]}
activeTab={section}
onChange={setSection}
activeTab={view}
onChange={(v) =>
navigate(
feedPath(
categorySlug,
defaultTab === "follow" && v === "users" ? undefined : "follow",
v,
),
{ replace: true },
)}
innerClassName="followed-sub-nav"
/>
{section === "users" && (
{view === "users" && (
<FollowedSubFeed
state={users.state}
emptyMessage={t`Follow some users to see their dumps here.`}
@@ -145,7 +158,7 @@ export function FollowedFeed(
/>
)}
{section === "playlists" && (
{view === "playlists" && (
<FollowedSubFeed
state={playlists.state}
emptyMessage={t`Follow some public playlists to see their dumps here.`}

View File

@@ -11,7 +11,7 @@ import { Navigate, useLocation } from "react-router";
import { AppHeader } from "../components/layout/AppHeader.tsx";
import { PresenceRow } from "../components/feed/PresenceRow.tsx";
import { FeedTabBar } from "../components/feed/FeedTabBar.tsx";
import { type FeedTab } from "../../shared/paths.ts";
import { type FeedTab, type FollowView } from "../../shared/paths.ts";
import { useViewerDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
import { API_URL } from "../config/api.ts";
@@ -44,11 +44,14 @@ export interface IndexProps {
categorySlug?: string;
/** The tab the path names, or none for the viewer's default one. */
feedTab?: FeedTab;
/** The view of the "follow" tab the path names, the users without one. */
followView?: FollowView;
}
/**
* Renders `/`, `/:feedTab`, `/:categorySlug` and `/:categorySlug/:feedTab`,
* for anyone, the "follow" tab for a signed-in user only. The feed comes from
* for anyone, the "follow" tab, and its `/follow/playlists`, for a signed-in
* user only. The feed comes from
* `GET /api/dumps`, or `GET /api/dumps?category=`, a page at a time,
* and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps
* posted since, the live vote counts, and who's online. The router keys the
@@ -57,7 +60,7 @@ export interface IndexProps {
* Android (`?share_url=`).
*/
export function Index(
{ categorySlug, feedTab }: IndexProps,
{ categorySlug, feedTab, followView = "users" }: IndexProps,
): React.JSX.Element {
const location = useLocation();
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
@@ -195,7 +198,11 @@ export function Index(
fitWithin=".app-header-center"
fitBeside=".feed-sort"
/>
<FeedTabBar categorySlug={categorySlug} tab={tab} />
<FeedTabBar
categorySlug={categorySlug}
tab={tab}
followView={followView}
/>
</div>
}
disableNew={dumpsState.status === "error"}
@@ -203,7 +210,11 @@ export function Index(
/>
<div className="index-below-header">
<FeedTabBar categorySlug={categorySlug} tab={tab} />
<FeedTabBar
categorySlug={categorySlug}
tab={tab}
followView={followView}
/>
<PresenceRow fitWithin=".index-below-header" fitBeside=".feed-sort" />
</div>
@@ -211,7 +222,9 @@ export function Index(
<DumpFeed order={tab} {...mainFeedProps} />
)}
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
{tab === "follow" && user && <FollowedFeed categorySlug={categorySlug} />}
{tab === "follow" && user && (
<FollowedFeed categorySlug={categorySlug} view={followView} />
)}
</div>
);
}

View File

@@ -65,4 +65,19 @@ describe("FeedTabBar", () => {
await user.click(screen.getByRole("button", { name: "Hot" }));
expect(currentPath()).toBe("/hot");
});
test("a category keeps the follow tab's view", async () => {
const user = userEvent.setup();
renderWithContexts(
<FeedTabBar categorySlug="music" tab="follow" followView="playlists" />,
{
session: makeSession(),
route: "/music/follow/playlists",
categories,
},
);
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("option", { name: "Cinema" }));
expect(currentPath()).toBe("/cinema/follow/playlists");
});
});

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([]),
);