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;