diff --git a/API.md b/API.md index a5de7d31..5c36e5eb 100644 --- a/API.md +++ b/API.md @@ -22,26 +22,26 @@ has one word, the same as in pages. ## Pages -| Path | Views | Access | Page | -| ------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------ | -| `/` | `/hot`, `/new`, `/journal`, `/follow`; the bare `/` is the viewer's default tab | anyone, `/follow` signed in | `Index` | -| `/` | `//hot`, `/new`, `/journal`, `/follow` | anyone | `Index` | -| `/@` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` | -| `/@/dumps`, `/upvoted`, `/playlists`, `/invitees` | | anyone | `UserPublicProfile` | -| `/@/follow` | `/@/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` | -| `/@/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` | -| `/@/dumps/` | | anyone; a private dump, its poster | `Dump` | -| `/@/dumps//edit` | | its poster, `dump:moderate` | `DumpEdit` | -| `/@/playlists/` | | anyone; a private playlist, its owner | `PlaylistDetail` | -| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` | -| `/notifications` | | signed in | `Notifications` | -| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` | -| `/settings` | | signed in | `Settings` | -| `/manage` | | `category:manage`, `federation:manage` | `Manage` | -| `/login`, `/register?token=` | `/login?next=` comes back to a page | signed out | `UserLogin`, `UserRegister` | -| `/authorize?client_id=…` | | signed in, else via `/login?next=` | `Authorize` | -| `/federation/callback?code=&state=` | | anyone | `FederationCallback` | -| `/reset-password?token=`, `/unsubscribe?token=` | | anyone | `ResetPassword`, `Unsubscribe` | +| Path | Views | Access | Page | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------ | +| `/` | `/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists`; the bare `/` is the viewer's default tab, the bare `/follow` the users followed | anyone, `/follow` signed in | `Index` | +| `/` | `//hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` | +| `/@` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` | +| `/@/dumps`, `/upvoted`, `/playlists`, `/invitees` | | anyone | `UserPublicProfile` | +| `/@/follow` | `/@/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` | +| `/@/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` | +| `/@/dumps/` | | anyone; a private dump, its poster | `Dump` | +| `/@/dumps//edit` | | its poster, `dump:moderate` | `DumpEdit` | +| `/@/playlists/` | | anyone; a private playlist, its owner | `PlaylistDetail` | +| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` | +| `/notifications` | | signed in | `Notifications` | +| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` | +| `/settings` | | signed in | `Settings` | +| `/manage` | | `category:manage`, `federation:manage` | `Manage` | +| `/login`, `/register?token=` | `/login?next=` comes back to a page | signed out | `UserLogin`, `UserRegister` | +| `/authorize?client_id=…` | | signed in, else via `/login?next=` | `Authorize` | +| `/federation/callback?code=&state=` | | anyone | `FederationCallback` | +| `/reset-password?token=`, `/unsubscribe?token=` | | anyone | `ResetPassword`, `Unsubscribe` | `/?share_url=` opens the create form on a link shared from Android, `/?chat=open` opens the chat over any page, and `#comment-` scrolls a dump's @@ -105,8 +105,6 @@ thumbnail, waveform, cover or attachment takes the owner's media token as - `GET /api/me/follow` — signed in. The ids of the users and playlists followed. - `PUT`, `DELETE /api/me/follow/users/:id`, `/api/me/follow/playlists/:id` — signed in. Follows or stops following. -- `GET /api/me/feed?from=users|playlists&category=` — signed in. The dumps of - what the caller follows. - `GET /api/me/notifications` — signed in. The caller's notifications. - `POST /api/me/notifications/read` — signed in. Marks them all read. - `GET /api/me/grants` — signed in. The other instances the caller's account @@ -140,6 +138,9 @@ there: those endpoints answer it 400. - `POST /api/dumps` — signed in. Posts a link (JSON) or a file (multipart). - `GET /api/dumps` — anyone. The feed; `?category=` narrows it, and `?url=` answers the dumps of the same link instead. +- `GET /api/dumps?follow=users|playlists` — signed in. The feed's `follow` tab: + the dumps of the users, or of the playlists, the caller follows; `?category=` + narrows it too. - `GET /api/dumps/:id` — anyone who may see it. A dump. - `GET /api/dumps/:id/voters`, `/related`, `/playlists` — the same. Who upvoted it, the dumps linked to or from it, the playlists holding it. diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index f6827679..fd5fa8ea 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -12,6 +12,12 @@ * dumps the caller may see, newest first. * - `GET /api/dumps?category=&page=&limit=` — optional sign-in. The same, in * the category with that slug; 404 when there is none. + * - `GET /api/dumps?follow=users&category=&page=&limit=` — signed in. The + * index's "follow" tab: a page of the public dumps of the users the caller + * follows, newest first, optionally in one category. + * - `GET /api/dumps?follow=playlists&category=&page=&limit=` — signed in. Its + * `/follow/playlists` view: a page of the public dumps in the public + * playlists the caller follows, most recently added first. * - `GET /api/dumps?url=` — optional sign-in. Answers the dumps already * pointing at the same thing as `url`, for the create form's duplicate hint. * - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump. @@ -50,7 +56,7 @@ import { toPublicUser } from "../model/user.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; import { parseOptionalAuth } from "../lib/auth.ts"; -import { parsePagination } from "../lib/pagination.ts"; +import { type Page, parsePagination } from "../lib/pagination.ts"; import { createFileDump, createUrlDump, @@ -61,7 +67,15 @@ import { refreshDumpMetadata, updateDump, } from "../services/dump-service.ts"; -import { listDumpsByCategory } from "../services/category-service.ts"; +import { + getCategoryBySlug, + listDumpsByCategory, +} from "../services/category-service.ts"; +import { + getFollowedPlaylistsDumpFeed, + getFollowedUsersDumpFeed, +} from "../services/follow-service.ts"; +import { FOLLOW_VIEWS, type FollowView } from "../../shared/paths.ts"; import { getDumpVoters } from "../services/vote-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts"; import { getPlaylistsForDump } from "../services/playlist-service.ts"; @@ -174,9 +188,25 @@ router.get("/", async (ctx) => { } const { page, limit } = parsePagination(params); const category = params.get("category"); - const { items, hasMore } = category - ? listDumpsByCategory(category, page, limit, requestingUserId) - : listDumps(page, limit, requestingUserId); + const follow = params.get("follow"); + let feed: Page; + if (follow !== null) { + if (!requestingUserId) throw new APIException(401, "Missing token"); + if (!(FOLLOW_VIEWS as readonly string[]).includes(follow)) { + throw new APIException(400, "follow must be users or playlists"); + } + const categoryId = category + ? getCategoryBySlug("dump", category).id + : undefined; + feed = (follow as FollowView) === "users" + ? getFollowedUsersDumpFeed(requestingUserId, page, limit, categoryId) + : getFollowedPlaylistsDumpFeed(requestingUserId, page, limit, categoryId); + } else { + feed = category + ? listDumpsByCategory(category, page, limit, requestingUserId) + : listDumps(page, limit, requestingUserId); + } + const { items, hasMore } = feed; const responseBody: APIResponse> = { success: true, data: { items, hasMore }, diff --git a/api/routes/follows.ts b/api/routes/follows.ts index cfb14480..031f455a 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -1,6 +1,8 @@ /** - * Serves what the caller follows, and the index's "follow" tab built from it. - * What another user follows is served by `routes/users.ts`. + * Serves what the caller follows, and follows or stops following for them. + * The dumps of what they follow, the index's "follow" tab, are + * `GET /api/dumps?follow=`'s (`routes/dumps.ts`), and what another user + * follows is `routes/users.ts`'s. * * - `GET /api/me/follow` — signed in. Answers the ids of the users and * playlists they follow. @@ -10,35 +12,21 @@ * playlist. * - `DELETE /api/me/follow/playlists/:playlistId` — signed in. Stops following * a playlist. - * - `GET /api/me/feed?from=users&page=&limit=&category=` — signed in. Answers - * a page of the public dumps of the users they follow, newest first, - * optionally in one category. - * - `GET /api/me/feed?from=playlists&page=&limit=&category=` — signed in. - * Answers a page of the public dumps in the public playlists they follow, - * most recently added first, optionally in one category. * * @module */ import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; -import { APIException } from "../lib/api-exception.ts"; -import type { APIResponse, PaginatedData } from "../../shared/api.ts"; -import type { Dump } from "../../shared/dump.ts"; +import type { APIResponse } from "../../shared/api.ts"; import type { FollowStatus } from "../../shared/follow.ts"; -import { type Page, parsePagination } from "../lib/pagination.ts"; import { followPlaylist, followUser, - getFollowedPlaylistsDumpFeed, - getFollowedUsersDumpFeed, getFollowStatus, unfollowPlaylist, unfollowUser, } from "../services/follow-service.ts"; -import { resolveCategoryFilter } from "../services/category-service.ts"; - -const EMPTY_FEED: Page = { items: [], hasMore: false }; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. @@ -72,25 +60,4 @@ router.delete("/follow/playlists/:playlistId", authMiddleware, (ctx) => { ctx.response.status = 204; }); -router.get("/feed", authMiddleware, (ctx) => { - const params = ctx.request.url.searchParams; - const from = params.get("from"); - if (from !== "users" && from !== "playlists") { - throw new APIException(400, "from must be users or playlists"); - } - const { page, limit } = parsePagination(params); - const categoryId = resolveCategoryFilter("dump", params); - const userId = ctx.state.user.userId; - const { items, hasMore } = categoryId === "missing" - ? EMPTY_FEED - : from === "users" - ? getFollowedUsersDumpFeed(userId, page, limit, categoryId) - : getFollowedPlaylistsDumpFeed(userId, page, limit, categoryId); - const body: APIResponse> = { - success: true, - data: { items, hasMore }, - }; - ctx.response.body = body; -}); - export default router; diff --git a/api/services/category-service.ts b/api/services/category-service.ts index e7b126db..0ff6ffd9 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -131,8 +131,8 @@ export function getCategoryIdBySlug( * Returns the id of the category of `kind` whose slug the `name` parameter of * `params`, `?category=` by default, gives, undefined without one, or * "missing" when it names none, so that the list comes back empty rather than - * unfiltered. The follows routes filter the index's "followed" tab with it, - * the users routes a profile's lists, and the search route its results. + * unfiltered. The users routes filter a profile's lists with it, and the + * search route its results. */ export function resolveCategoryFilter( kind: CategoryKind, diff --git a/api/test/routes/follows.test.ts b/api/test/routes/follows.test.ts index 0e1f4683..d1b50ad9 100644 --- a/api/test/routes/follows.test.ts +++ b/api/test/routes/follows.test.ts @@ -137,25 +137,28 @@ test("followed-users feed: public dumps of the people I follow, newest first", a insertDump({ userId: stranger.id, title: "stranger" }); await put(`/api/me/follow/users/${followed.id}`, { auth: me.auth }); - const feed = await get>("/api/me/feed?from=users", { + const feed = await get>("/api/dumps?follow=users", { auth: me.auth, }); assertEquals(titles(feed), ["new", "old"]); assertEquals(feed.data.items[0].categoryIds, [music]); assertEquals( titles( - await get("/api/me/feed?from=users&category=music", { auth: me.auth }), + await get("/api/dumps?follow=users&category=music", { auth: me.auth }), ), ["new"], ); - // An unknown category filters everything out rather than nothing. + // As on the whole feed, an unknown category is a 404. assertEquals( - (await get>("/api/me/feed?from=users&category=nope", { - auth: me.auth, - })).data, - { items: [], hasMore: false }, + (await get("/api/dumps?follow=users&category=nope", { auth: me.auth })) + .status, + 404, ); - assertEquals((await get("/api/me/feed?from=users")).status, 401); + assertEquals( + (await get("/api/dumps?follow=dumps", { auth: me.auth })).status, + 400, + ); + assertEquals((await get("/api/dumps?follow=users")).status, 401); }); test("followed-playlists feed: each dump once, public playlists only", async () => { @@ -174,7 +177,7 @@ test("followed-playlists feed: each dump once, public playlists only", async () await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth }); } await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth }); - const feed = await get>("/api/me/feed?from=playlists", { + const feed = await get>("/api/dumps?follow=playlists", { auth: me.auth, }); assertEquals(titles(feed), ["shared"]); @@ -183,7 +186,7 @@ test("followed-playlists feed: each dump once, public playlists only", async () // A playlist gone private drops out of the feed. db.prepare(`UPDATE playlists SET is_public = 0;`).run(); assertEquals( - (await get>("/api/me/feed?from=playlists", { + (await get>("/api/dumps?follow=playlists", { auth: me.auth, })).data .items.length, diff --git a/shared/paths.ts b/shared/paths.ts index cc53b1b0..32c13877 100644 --- a/shared/paths.ts +++ b/shared/paths.ts @@ -46,8 +46,10 @@ export const PROFILE_TABS = [ export type ProfileTab = (typeof PROFILE_TABS)[number]; /** - * The views of a profile's `follow` tab: the users followed at - * `/@name/follow`, the playlists at `/@name/follow/playlists`. + * The views of a `follow` tab, of what is followed: the users, at the bare + * tab, or the playlists. A profile's lists them, `/@name/follow` and + * `/@name/follow/playlists`, and a feed's shows their dumps, `/follow` and + * `/follow/playlists`. */ export const FOLLOW_VIEWS = ["users", "playlists"] as const; /** One of the {@link FOLLOW_VIEWS}. */ @@ -112,11 +114,19 @@ export function playlistPath(playlist: OwnedItem): string { /** * Returns the path of a feed: every dump's at the root, or a category's at - * `/`, on `tab`, or on the viewer's default tab without one. + * `/`, on `tab`, or on the viewer's default tab without one. The + * `follow` tab takes a `view`, the users followed by default. */ -export function feedPath(categorySlug?: string, tab?: FeedTab): string { +export function feedPath( + categorySlug?: string, + tab?: FeedTab, + view?: FollowView, +): string { const base = categorySlug ? `/${seg(categorySlug)}` : ""; - return tab ? `${base}/${tab}` : base || "/"; + if (!tab) return base || "/"; + return tab === "follow" && view && view !== FOLLOW_VIEWS[0] + ? `${base}/${tab}/${view}` + : `${base}/${tab}`; } /** diff --git a/shared/test/paths.test.ts b/shared/test/paths.test.ts index 75a1caa8..75fa913f 100644 --- a/shared/test/paths.test.ts +++ b/shared/test/paths.test.ts @@ -24,6 +24,12 @@ Deno.test("builders: one hierarchy, default views bare", () => { assertEquals(feedPath(undefined, "hot"), "/hot"); assertEquals(feedPath("music"), "/music"); assertEquals(feedPath("music", "follow"), "/music/follow"); + assertEquals(feedPath("music", "follow", "users"), "/music/follow"); + assertEquals( + feedPath(undefined, "follow", "playlists"), + "/follow/playlists", + ); + assertEquals(feedPath(undefined, "new", "playlists"), "/new"); assertEquals(searchPath(), "/search"); assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b"); assertEquals(searchPath("dumps", "x"), "/search?q=x"); diff --git a/src/App.tsx b/src/App.tsx index b7d4f7a1..26c90dfd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ; } @@ -152,6 +161,7 @@ function Feed({ categorySlug }: { categorySlug?: string }) { key={categorySlug ?? ""} categorySlug={categorySlug} feedTab={feedTab as FeedTab | undefined} + followView={view as FollowView | undefined} /> ); } diff --git a/src/components/feed/FeedTabBar.tsx b/src/components/feed/FeedTabBar.tsx index 3df7a5bc..f7acaa91 100644 --- a/src/components/feed/FeedTabBar.tsx +++ b/src/components/feed/FeedTabBar.tsx @@ -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: Hot }, @@ -49,14 +66,12 @@ export function FeedTabBar( - navigate(feedPath(categorySlug, t === defaultTab ? undefined : t))} + onChange={(t) => navigate(pathOf(categorySlug, t), { replace: true })} leading={ - navigate(feedPath(slug, tab === defaultTab ? undefined : tab))} + onSelect={(slug) => navigate(pathOf(slug, tab, followView))} /> } /> diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx index cb5c640f..7d35154a 100644 --- a/src/components/feed/FollowedFeed.tsx +++ b/src/components/feed/FollowedFeed.tsx @@ -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=
`, 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=`, 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("users"); + const users = useFollowedView("users", categorySlug); + const playlists = useFollowedView("playlists", categorySlug); + const navigate = useNavigate(); + const defaultTab = useViewerDefaultFeedTab(); return (
@@ -132,12 +137,20 @@ export function FollowedFeed( { key: "users", label: From people }, { key: "playlists", label: From playlists }, ]} - 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" && ( )} - {section === "playlists" && ( + {view === "playlists" && ( - +
} disableNew={dumpsState.status === "error"} @@ -203,7 +210,11 @@ export function Index( />
- +
@@ -211,7 +222,9 @@ export function Index( )} {tab === "journal" && } - {tab === "follow" && user && } + {tab === "follow" && user && ( + + )} ); } diff --git a/src/test/components/feed/FeedTabBar.test.tsx b/src/test/components/feed/FeedTabBar.test.tsx index 5bea8cad..189df674 100644 --- a/src/test/components/feed/FeedTabBar.test.tsx +++ b/src/test/components/feed/FeedTabBar.test.tsx @@ -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( + , + { + 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"); + }); }); diff --git a/src/test/pages/Index.test.tsx b/src/test/pages/Index.test.tsx index 912530a0..bb4ee780 100644 --- a/src/test/pages/Index.test.tsx +++ b/src/test/pages/Index.test.tsx @@ -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 ( ); } @@ -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([]), );