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

@@ -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<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)
: listDumps(page, limit, requestingUserId);
}
const { items, hasMore } = feed;
const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, hasMore },

View File

@@ -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<Dump> = { 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<PaginatedData<Dump>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = body;
});
export default router;

View File

@@ -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,

View File

@@ -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<PaginatedData<Dump>>("/api/me/feed?from=users", {
const feed = await get<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/api/me/feed?from=playlists", {
const feed = await get<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/api/me/feed?from=playlists", {
(await get<PaginatedData<Dump>>("/api/dumps?follow=playlists", {
auth: me.auth,
})).data
.items.length,