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:
11
API.md
11
API.md
@@ -23,9 +23,9 @@ has one word, the same as in pages.
|
|||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
| Path | Views | Access | Page |
|
| 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`, `/follow/playlists`; the bare `/` is the viewer's default tab, the bare `/follow` the users followed | anyone, `/follow` signed in | `Index` |
|
||||||
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow` | anyone | `Index` |
|
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` |
|
||||||
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
|
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
|
||||||
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | | anyone | `UserPublicProfile` |
|
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | | anyone | `UserPublicProfile` |
|
||||||
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
|
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
|
||||||
@@ -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.
|
- `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` —
|
- `PUT`, `DELETE /api/me/follow/users/:id`, `/api/me/follow/playlists/:id` —
|
||||||
signed in. Follows or stops following.
|
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.
|
- `GET /api/me/notifications` — signed in. The caller's notifications.
|
||||||
- `POST /api/me/notifications/read` — signed in. Marks them all read.
|
- `POST /api/me/notifications/read` — signed in. Marks them all read.
|
||||||
- `GET /api/me/grants` — signed in. The other instances the caller's account
|
- `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).
|
- `POST /api/dumps` — signed in. Posts a link (JSON) or a file (multipart).
|
||||||
- `GET /api/dumps` — anyone. The feed; `?category=<slug>` narrows it, and
|
- `GET /api/dumps` — anyone. The feed; `?category=<slug>` narrows it, and
|
||||||
`?url=` answers the dumps of the same link instead.
|
`?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` — anyone who may see it. A dump.
|
||||||
- `GET /api/dumps/:id/voters`, `/related`, `/playlists` — the same. Who upvoted
|
- `GET /api/dumps/:id/voters`, `/related`, `/playlists` — the same. Who upvoted
|
||||||
it, the dumps linked to or from it, the playlists holding it.
|
it, the dumps linked to or from it, the playlists holding it.
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
* dumps the caller may see, newest first.
|
* dumps the caller may see, newest first.
|
||||||
* - `GET /api/dumps?category=&page=&limit=` — optional sign-in. The same, in
|
* - `GET /api/dumps?category=&page=&limit=` — optional sign-in. The same, in
|
||||||
* the category with that slug; 404 when there is none.
|
* 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
|
* - `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.
|
* pointing at the same thing as `url`, for the create form's duplicate hint.
|
||||||
* - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump.
|
* - `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 { authMiddleware } from "../middleware/auth.ts";
|
||||||
import { can } from "../lib/permissions.ts";
|
import { can } from "../lib/permissions.ts";
|
||||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||||
import { parsePagination } from "../lib/pagination.ts";
|
import { type Page, parsePagination } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
createFileDump,
|
createFileDump,
|
||||||
createUrlDump,
|
createUrlDump,
|
||||||
@@ -61,7 +67,15 @@ import {
|
|||||||
refreshDumpMetadata,
|
refreshDumpMetadata,
|
||||||
updateDump,
|
updateDump,
|
||||||
} from "../services/dump-service.ts";
|
} 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 { getDumpVoters } from "../services/vote-service.ts";
|
||||||
import { getRelatedDumps } from "../services/backlink-service.ts";
|
import { getRelatedDumps } from "../services/backlink-service.ts";
|
||||||
import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||||
@@ -174,9 +188,25 @@ router.get("/", async (ctx) => {
|
|||||||
}
|
}
|
||||||
const { page, limit } = parsePagination(params);
|
const { page, limit } = parsePagination(params);
|
||||||
const category = params.get("category");
|
const category = params.get("category");
|
||||||
const { items, hasMore } = category
|
const follow = params.get("follow");
|
||||||
|
let feed: Page<Dump>;
|
||||||
|
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)
|
? listDumpsByCategory(category, page, limit, requestingUserId)
|
||||||
: listDumps(page, limit, requestingUserId);
|
: listDumps(page, limit, requestingUserId);
|
||||||
|
}
|
||||||
|
const { items, hasMore } = feed;
|
||||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, hasMore },
|
data: { items, hasMore },
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Serves what the caller follows, and the index's "follow" tab built from it.
|
* Serves what the caller follows, and follows or stops following for them.
|
||||||
* What another user follows is served by `routes/users.ts`.
|
* 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
|
* - `GET /api/me/follow` — signed in. Answers the ids of the users and
|
||||||
* playlists they follow.
|
* playlists they follow.
|
||||||
@@ -10,35 +12,21 @@
|
|||||||
* playlist.
|
* playlist.
|
||||||
* - `DELETE /api/me/follow/playlists/:playlistId` — signed in. Stops following
|
* - `DELETE /api/me/follow/playlists/:playlistId` — signed in. Stops following
|
||||||
* a playlist.
|
* 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
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import { APIException } from "../lib/api-exception.ts";
|
import type { APIResponse } from "../../shared/api.ts";
|
||||||
import type { APIResponse, PaginatedData } from "../../shared/api.ts";
|
|
||||||
import type { Dump } from "../../shared/dump.ts";
|
|
||||||
import type { FollowStatus } from "../../shared/follow.ts";
|
import type { FollowStatus } from "../../shared/follow.ts";
|
||||||
import { type Page, parsePagination } from "../lib/pagination.ts";
|
|
||||||
import {
|
import {
|
||||||
followPlaylist,
|
followPlaylist,
|
||||||
followUser,
|
followUser,
|
||||||
getFollowedPlaylistsDumpFeed,
|
|
||||||
getFollowedUsersDumpFeed,
|
|
||||||
getFollowStatus,
|
getFollowStatus,
|
||||||
unfollowPlaylist,
|
unfollowPlaylist,
|
||||||
unfollowUser,
|
unfollowUser,
|
||||||
} from "../services/follow-service.ts";
|
} from "../services/follow-service.ts";
|
||||||
import { resolveCategoryFilter } from "../services/category-service.ts";
|
|
||||||
|
|
||||||
const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
* 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;
|
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<PaginatedData<Dump>> = {
|
|
||||||
success: true,
|
|
||||||
data: { items, hasMore },
|
|
||||||
};
|
|
||||||
ctx.response.body = body;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ export function getCategoryIdBySlug(
|
|||||||
* Returns the id of the category of `kind` whose slug the `name` parameter of
|
* Returns the id of the category of `kind` whose slug the `name` parameter of
|
||||||
* `params`, `?category=` by default, gives, undefined without one, or
|
* `params`, `?category=` by default, gives, undefined without one, or
|
||||||
* "missing" when it names none, so that the list comes back empty rather than
|
* "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,
|
* unfiltered. The users routes filter a profile's lists with it, and the
|
||||||
* the users routes a profile's lists, and the search route its results.
|
* search route its results.
|
||||||
*/
|
*/
|
||||||
export function resolveCategoryFilter(
|
export function resolveCategoryFilter(
|
||||||
kind: CategoryKind,
|
kind: CategoryKind,
|
||||||
|
|||||||
@@ -137,25 +137,28 @@ test("followed-users feed: public dumps of the people I follow, newest first", a
|
|||||||
insertDump({ userId: stranger.id, title: "stranger" });
|
insertDump({ userId: stranger.id, title: "stranger" });
|
||||||
await put(`/api/me/follow/users/${followed.id}`, { auth: me.auth });
|
await put(`/api/me/follow/users/${followed.id}`, { auth: me.auth });
|
||||||
|
|
||||||
const feed = await get<PaginatedData<Dump>>("/api/me/feed?from=users", {
|
const feed = await get<PaginatedData<Dump>>("/api/dumps?follow=users", {
|
||||||
auth: me.auth,
|
auth: me.auth,
|
||||||
});
|
});
|
||||||
assertEquals(titles(feed), ["new", "old"]);
|
assertEquals(titles(feed), ["new", "old"]);
|
||||||
assertEquals(feed.data.items[0].categoryIds, [music]);
|
assertEquals(feed.data.items[0].categoryIds, [music]);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
titles(
|
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"],
|
["new"],
|
||||||
);
|
);
|
||||||
// An unknown category filters everything out rather than nothing.
|
// As on the whole feed, an unknown category is a 404.
|
||||||
assertEquals(
|
assertEquals(
|
||||||
(await get<PaginatedData<Dump>>("/api/me/feed?from=users&category=nope", {
|
(await get("/api/dumps?follow=users&category=nope", { auth: me.auth }))
|
||||||
auth: me.auth,
|
.status,
|
||||||
})).data,
|
404,
|
||||||
{ items: [], hasMore: false },
|
|
||||||
);
|
);
|
||||||
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 () => {
|
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/${p.id}/dumps/${d.id}`, { auth: owner.auth });
|
||||||
}
|
}
|
||||||
await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth });
|
await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth });
|
||||||
const feed = await get<PaginatedData<Dump>>("/api/me/feed?from=playlists", {
|
const feed = await get<PaginatedData<Dump>>("/api/dumps?follow=playlists", {
|
||||||
auth: me.auth,
|
auth: me.auth,
|
||||||
});
|
});
|
||||||
assertEquals(titles(feed), ["shared"]);
|
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.
|
// A playlist gone private drops out of the feed.
|
||||||
db.prepare(`UPDATE playlists SET is_public = 0;`).run();
|
db.prepare(`UPDATE playlists SET is_public = 0;`).run();
|
||||||
assertEquals(
|
assertEquals(
|
||||||
(await get<PaginatedData<Dump>>("/api/me/feed?from=playlists", {
|
(await get<PaginatedData<Dump>>("/api/dumps?follow=playlists", {
|
||||||
auth: me.auth,
|
auth: me.auth,
|
||||||
})).data
|
})).data
|
||||||
.items.length,
|
.items.length,
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ export const PROFILE_TABS = [
|
|||||||
export type ProfileTab = (typeof PROFILE_TABS)[number];
|
export type ProfileTab = (typeof PROFILE_TABS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The views of a profile's `follow` tab: the users followed at
|
* The views of a `follow` tab, of what is followed: the users, at the bare
|
||||||
* `/@name/follow`, the playlists at `/@name/follow/playlists`.
|
* 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;
|
export const FOLLOW_VIEWS = ["users", "playlists"] as const;
|
||||||
/** One of the {@link FOLLOW_VIEWS}. */
|
/** 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
|
* Returns the path of a feed: every dump's at the root, or a category's at
|
||||||
* `/<slug>`, on `tab`, or on the viewer's default tab without one.
|
* `/<slug>`, 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)}` : "";
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ Deno.test("builders: one hierarchy, default views bare", () => {
|
|||||||
assertEquals(feedPath(undefined, "hot"), "/hot");
|
assertEquals(feedPath(undefined, "hot"), "/hot");
|
||||||
assertEquals(feedPath("music"), "/music");
|
assertEquals(feedPath("music"), "/music");
|
||||||
assertEquals(feedPath("music", "follow"), "/music/follow");
|
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(), "/search");
|
||||||
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
|
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
|
||||||
assertEquals(searchPath("dumps", "x"), "/search?q=x");
|
assertEquals(searchPath("dumps", "x"), "/search?q=x");
|
||||||
|
|||||||
32
src/App.tsx
32
src/App.tsx
@@ -5,8 +5,9 @@
|
|||||||
* more segment, and a default view the bare path, with no redirect.
|
* 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`,
|
* - `/` is the feed of every dump on the viewer's default tab, and `/hot`,
|
||||||
* `/new`, `/journal` and `/follow` its tabs (`Index`). `/?share_url=` opens
|
* `/new`, `/journal` and `/follow` its tabs (`Index`), `/follow/playlists`
|
||||||
* the create form on a shared link.
|
* 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`).
|
* - `/search` and `/search/:searchTab` are the search results (`Search`).
|
||||||
* - `/register` and `/login` sign up and sign in (`UserRegister`,
|
* - `/register` and `/login` sign up and sign in (`UserRegister`,
|
||||||
* `UserLogin`), signed out only, `/login?next=` coming back to a page.
|
* `UserLogin`), signed out only, `/login?next=` coming back to a page.
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
* signed in only), and their playlists at `playlists/:slug`
|
* signed in only), and their playlists at `playlists/:slug`
|
||||||
* (`PlaylistDetail`).
|
* (`PlaylistDetail`).
|
||||||
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
|
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
|
||||||
* (`Index`).
|
* (`Index`), with its `/follow/playlists` too.
|
||||||
* - Any other path is `NotFound`.
|
* - Any other path is `NotFound`.
|
||||||
*
|
*
|
||||||
* The pages not marked are open to anyone. Every page is loaded with its
|
* 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 { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
|
||||||
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.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 { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
|
||||||
import { PlayerProvider } from "./contexts/player/PlayerProvider.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
|
* 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
|
* 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
|
* path, and the `follow` tab on the view the next segment names. Every feed
|
||||||
* keeps `Index` mounted on a change of tab, which sorts on the client, and
|
* path renders it at the same place in the tree, and the key keeps `Index`
|
||||||
* remounts it on a change of category, so that it refetches.
|
* 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 }) {
|
function Feed({ categorySlug }: { categorySlug?: string }) {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
const rest = segments.slice(categorySlug ? 1 : 0);
|
const [feedTab, view, ...more] = segments.slice(categorySlug ? 1 : 0);
|
||||||
const feedTab = rest[0];
|
|
||||||
if (
|
if (
|
||||||
rest.length > 1 ||
|
more.length > 0 ||
|
||||||
(feedTab && !(FEED_TABS as readonly string[]).includes(feedTab))
|
(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 />;
|
return <NotFound />;
|
||||||
}
|
}
|
||||||
@@ -152,6 +161,7 @@ function Feed({ categorySlug }: { categorySlug?: string }) {
|
|||||||
key={categorySlug ?? ""}
|
key={categorySlug ?? ""}
|
||||||
categorySlug={categorySlug}
|
categorySlug={categorySlug}
|
||||||
feedTab={feedTab as FeedTab | undefined}
|
feedTab={feedTab as FeedTab | undefined}
|
||||||
|
followView={view as FollowView | undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { useNavigate } from "react-router";
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.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 { TabBar } from "../shared/TabBar.tsx";
|
||||||
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
||||||
|
|
||||||
@@ -19,22 +24,34 @@ export interface FeedTabBarProps {
|
|||||||
categorySlug?: string;
|
categorySlug?: string;
|
||||||
/** The tab shown, the viewer's default one on a bare path. */
|
/** The tab shown, the viewer's default one on a bare path. */
|
||||||
tab: FeedTab;
|
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
|
* 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
|
* 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`.
|
* a category, and the viewer's default tab to the bare `/` or `/music`. A tab
|
||||||
* Picking a category keeps the tab, from `/music/new` to `/cinema/new`.
|
* 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
|
* `Index` renders it twice, in the header and below it, and CSS shows one by
|
||||||
* screen width.
|
* screen width.
|
||||||
*/
|
*/
|
||||||
export function FeedTabBar(
|
export function FeedTabBar(
|
||||||
{ categorySlug, tab }: FeedTabBarProps,
|
{ categorySlug, tab, followView }: FeedTabBarProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const defaultTab = useViewerDefaultFeedTab();
|
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 = [
|
const tabs = [
|
||||||
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
|
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
|
||||||
@@ -49,14 +66,12 @@ export function FeedTabBar(
|
|||||||
<TabBar
|
<TabBar
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={tab}
|
activeTab={tab}
|
||||||
onChange={(t) =>
|
onChange={(t) => navigate(pathOf(categorySlug, t), { replace: true })}
|
||||||
navigate(feedPath(categorySlug, t === defaultTab ? undefined : t))}
|
|
||||||
leading={
|
leading={
|
||||||
<CategorySwitcher
|
<CategorySwitcher
|
||||||
kind="dump"
|
kind="dump"
|
||||||
categorySlug={categorySlug}
|
categorySlug={categorySlug}
|
||||||
onSelect={(slug) =>
|
onSelect={(slug) => navigate(pathOf(slug, tab, followView))}
|
||||||
navigate(feedPath(slug, tab === defaultTab ? undefined : tab))}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
import { TabBar } from "../shared/TabBar.tsx";
|
import { TabBar } from "../shared/TabBar.tsx";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
@@ -26,20 +27,20 @@ import {
|
|||||||
} from "../../utils/shared/deserialize.ts";
|
} from "../../utils/shared/deserialize.ts";
|
||||||
import type { Dump } from "../../../shared/dump.ts";
|
import type { Dump } from "../../../shared/dump.ts";
|
||||||
import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts";
|
import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts";
|
||||||
|
import { feedPath, type FollowView } from "../../../shared/paths.ts";
|
||||||
type FollowedSection = "users" | "playlists";
|
import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns one section's feed, from `GET /api/me/feed?from=<section>`, and the
|
* Returns one view's feed, from `GET /api/dumps?follow=<view>`, and the call
|
||||||
* call that loads its next page. A section cached like the index's feed starts
|
* that loads its next page. A view cached like the index's feed starts loaded
|
||||||
* loaded and isn't fetched.
|
* and isn't fetched.
|
||||||
*/
|
*/
|
||||||
function useFollowedSection(
|
function useFollowedView(
|
||||||
section: FollowedSection,
|
section: FollowView,
|
||||||
categorySlug: string | undefined,
|
categorySlug: string | undefined,
|
||||||
): { state: DumpFeedState; loadMore: () => void } {
|
): { state: DumpFeedState; loadMore: () => void } {
|
||||||
const { user, authFetch } = useAuth();
|
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)}` : ""
|
categorySlug ? `&category=${encodeURIComponent(categorySlug)}` : ""
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
@@ -108,22 +109,26 @@ function FollowedSubFeed(
|
|||||||
* signed-in user only. Unlike the other tabs, it loads its own dumps.
|
* signed-in user only. Unlike the other tabs, it loads its own dumps.
|
||||||
*/
|
*/
|
||||||
export interface FollowedFeedProps {
|
export interface FollowedFeedProps {
|
||||||
/** Scopes both sections to a category. */
|
/** Scopes both views to a category. */
|
||||||
categorySlug?: string;
|
categorySlug?: string;
|
||||||
|
/** The view the path names: `/follow`, or `/follow/playlists`. */
|
||||||
|
view: FollowView;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the "followed" tab in two sections, "from people" and "from
|
* Renders the "follow" tab in two views, "from people" at `/follow` and "from
|
||||||
* playlists", from `GET /api/me/feed?from=users` and
|
* playlists" at `/follow/playlists`, from `GET /api/dumps?follow=users` and
|
||||||
* `GET /api/me/feed?from=playlists`. Both load at once, and each keeps its own
|
* `GET /api/dumps?follow=playlists`. Both load at once, so switching is
|
||||||
* pages, cached like the index's feed.
|
* 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(
|
export function FollowedFeed(
|
||||||
{ categorySlug }: FollowedFeedProps,
|
{ categorySlug, view }: FollowedFeedProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const users = useFollowedSection("users", categorySlug);
|
const users = useFollowedView("users", categorySlug);
|
||||||
const playlists = useFollowedSection("playlists", categorySlug);
|
const playlists = useFollowedView("playlists", categorySlug);
|
||||||
const [section, setSection] = useState<FollowedSection>("users");
|
const navigate = useNavigate();
|
||||||
|
const defaultTab = useViewerDefaultFeedTab();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="followed-feed">
|
<div className="followed-feed">
|
||||||
@@ -132,12 +137,20 @@ export function FollowedFeed(
|
|||||||
{ key: "users", label: <Trans>From people</Trans> },
|
{ key: "users", label: <Trans>From people</Trans> },
|
||||||
{ key: "playlists", label: <Trans>From playlists</Trans> },
|
{ key: "playlists", label: <Trans>From playlists</Trans> },
|
||||||
]}
|
]}
|
||||||
activeTab={section}
|
activeTab={view}
|
||||||
onChange={setSection}
|
onChange={(v) =>
|
||||||
|
navigate(
|
||||||
|
feedPath(
|
||||||
|
categorySlug,
|
||||||
|
defaultTab === "follow" && v === "users" ? undefined : "follow",
|
||||||
|
v,
|
||||||
|
),
|
||||||
|
{ replace: true },
|
||||||
|
)}
|
||||||
innerClassName="followed-sub-nav"
|
innerClassName="followed-sub-nav"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{section === "users" && (
|
{view === "users" && (
|
||||||
<FollowedSubFeed
|
<FollowedSubFeed
|
||||||
state={users.state}
|
state={users.state}
|
||||||
emptyMessage={t`Follow some users to see their dumps here.`}
|
emptyMessage={t`Follow some users to see their dumps here.`}
|
||||||
@@ -145,7 +158,7 @@ export function FollowedFeed(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "playlists" && (
|
{view === "playlists" && (
|
||||||
<FollowedSubFeed
|
<FollowedSubFeed
|
||||||
state={playlists.state}
|
state={playlists.state}
|
||||||
emptyMessage={t`Follow some public playlists to see their dumps here.`}
|
emptyMessage={t`Follow some public playlists to see their dumps here.`}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Navigate, useLocation } from "react-router";
|
|||||||
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
||||||
import { PresenceRow } from "../components/feed/PresenceRow.tsx";
|
import { PresenceRow } from "../components/feed/PresenceRow.tsx";
|
||||||
import { FeedTabBar } from "../components/feed/FeedTabBar.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 { useViewerDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
@@ -44,11 +44,14 @@ export interface IndexProps {
|
|||||||
categorySlug?: string;
|
categorySlug?: string;
|
||||||
/** The tab the path names, or none for the viewer's default one. */
|
/** The tab the path names, or none for the viewer's default one. */
|
||||||
feedTab?: FeedTab;
|
feedTab?: FeedTab;
|
||||||
|
/** The view of the "follow" tab the path names, the users without one. */
|
||||||
|
followView?: FollowView;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders `/`, `/:feedTab`, `/:categorySlug` and `/:categorySlug/:feedTab`,
|
* 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,
|
* `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
|
* 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
|
* posted since, the live vote counts, and who's online. The router keys the
|
||||||
@@ -57,7 +60,7 @@ export interface IndexProps {
|
|||||||
* Android (`?share_url=`).
|
* Android (`?share_url=`).
|
||||||
*/
|
*/
|
||||||
export function Index(
|
export function Index(
|
||||||
{ categorySlug, feedTab }: IndexProps,
|
{ categorySlug, feedTab, followView = "users" }: IndexProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||||
@@ -195,7 +198,11 @@ export function Index(
|
|||||||
fitWithin=".app-header-center"
|
fitWithin=".app-header-center"
|
||||||
fitBeside=".feed-sort"
|
fitBeside=".feed-sort"
|
||||||
/>
|
/>
|
||||||
<FeedTabBar categorySlug={categorySlug} tab={tab} />
|
<FeedTabBar
|
||||||
|
categorySlug={categorySlug}
|
||||||
|
tab={tab}
|
||||||
|
followView={followView}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
disableNew={dumpsState.status === "error"}
|
disableNew={dumpsState.status === "error"}
|
||||||
@@ -203,7 +210,11 @@ export function Index(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="index-below-header">
|
<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" />
|
<PresenceRow fitWithin=".index-below-header" fitBeside=".feed-sort" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,7 +222,9 @@ export function Index(
|
|||||||
<DumpFeed order={tab} {...mainFeedProps} />
|
<DumpFeed order={tab} {...mainFeedProps} />
|
||||||
)}
|
)}
|
||||||
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
|
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
|
||||||
{tab === "follow" && user && <FollowedFeed categorySlug={categorySlug} />}
|
{tab === "follow" && user && (
|
||||||
|
<FollowedFeed categorySlug={categorySlug} view={followView} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,4 +65,19 @@ describe("FeedTabBar", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Hot" }));
|
await user.click(screen.getByRole("button", { name: "Hot" }));
|
||||||
expect(currentPath()).toBe("/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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { act, screen, waitFor, within } from "@testing-library/react";
|
|||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { Index } from "../../pages/Index.tsx";
|
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 { calls, fail, route } from "../fetch.ts";
|
||||||
import {
|
import {
|
||||||
makeCategory,
|
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. */
|
/** Reads a feed's path as `App`'s routes do. */
|
||||||
function Scoped() {
|
function Scoped() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [first, second] = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
const isTab = (FEED_TABS as readonly string[]).includes(first ?? "");
|
const isTab = (FEED_TABS as readonly string[]).includes(segments[0] ?? "");
|
||||||
const categorySlug = first && !isTab ? first : undefined;
|
const categorySlug = isTab ? undefined : segments[0];
|
||||||
|
const [feedTab, followView] = segments.slice(isTab ? 0 : 1);
|
||||||
return (
|
return (
|
||||||
<Index
|
<Index
|
||||||
key={categorySlug ?? ""}
|
key={categorySlug ?? ""}
|
||||||
categorySlug={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 () => {
|
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");
|
localStorage.setItem("default-feed-tab", "followed");
|
||||||
setup("/", { session: makeSession() });
|
setup("/", { session: makeSession() });
|
||||||
expect(
|
expect(
|
||||||
@@ -273,13 +279,16 @@ describe("Index feeds", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Followed feed", () => {
|
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();
|
const session = makeSession();
|
||||||
route(
|
route(
|
||||||
"GET /api/me/feed",
|
"GET /api/dumps",
|
||||||
(req) =>
|
(req) =>
|
||||||
page(
|
page(
|
||||||
req.url.searchParams.get("from") === "users"
|
req.url.searchParams.get("follow") === "users"
|
||||||
? [makeDump({ title: "By a friend" })]
|
? [makeDump({ title: "By a friend" })]
|
||||||
: [],
|
: [],
|
||||||
),
|
),
|
||||||
@@ -287,20 +296,25 @@ describe("Followed feed", () => {
|
|||||||
const { user } = setup("/follow", { session });
|
const { user } = setup("/follow", { session });
|
||||||
await screen.findByText("By a friend");
|
await screen.findByText("By a friend");
|
||||||
await user.click(screen.getByRole("button", { name: "From playlists" }));
|
await user.click(screen.getByRole("button", { name: "From playlists" }));
|
||||||
|
expect(currentPath()).toBe("/follow/playlists");
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Follow some public playlists to see their dumps here."),
|
screen.getByText("Follow some public playlists to see their dumps here."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
const [feed] = calls("GET /api/me/feed");
|
const [feed] = followCalls();
|
||||||
expect(feed.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
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 () => {
|
test("its view is in the path, a category's too", async () => {
|
||||||
route("GET /api/me/feed", () => page([]));
|
route("GET /api/dumps", () => page([]));
|
||||||
setup("/music/follow", { session: makeSession() });
|
setup("/music/follow/playlists", { session: makeSession() });
|
||||||
await screen.findByText("Follow some users to see their dumps here.");
|
await screen.findByText(
|
||||||
|
"Follow some public playlists to see their dumps here.",
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
calls("GET /api/me/feed").map((r) => [
|
followCalls().map((r) => [
|
||||||
r.url.searchParams.get("from"),
|
r.url.searchParams.get("follow"),
|
||||||
r.url.searchParams.get("category"),
|
r.url.searchParams.get("category"),
|
||||||
]).sort(),
|
]).sort(),
|
||||||
).toEqual([["playlists", "music"], ["users", "music"]]);
|
).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 () => {
|
test("a server error reads as one, not as a lost connection", async () => {
|
||||||
route(
|
route(
|
||||||
"GET /api/me/feed",
|
"GET /api/dumps",
|
||||||
(req) =>
|
(req) =>
|
||||||
req.url.searchParams.get("from") === "users"
|
req.url.searchParams.get("follow") === "users"
|
||||||
? fail("Internal error", 500)
|
? fail("Internal error", 500)
|
||||||
: page([]),
|
: page([]),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user