v4: every request about a user, and the dump editor's path, come from one builder each — fourteen places wrote /api/users/<key>/… by hand, some encoding a handle (@alice%40music.example) and some not, a slug never; userApiUrl (src/utils/shared/urls.ts) now writes them all, by the user's id or by @ and the handle a page's path gives, each segment encoded. The editor's path was the dump's page's with /edit added in two places; dumpPath takes the view, as userPath and feedPath do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 22:18:13 +00:00
parent 669e65a3a2
commit 21f4db268e
15 changed files with 69 additions and 36 deletions

View File

@@ -102,9 +102,13 @@ export function userPath(
: `${base}/${tab}`;
}
/** Returns the path of a dump's page, `/@owner/dumps/<slug>`. */
export function dumpPath(dump: OwnedItem): string {
return `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`;
/**
* Returns the path of a dump's page, `/@owner/dumps/<slug>`, or of its
* editor, one segment more.
*/
export function dumpPath(dump: OwnedItem, view?: "edit"): string {
const path = `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`;
return view ? `${path}/${view}` : path;
}
/** Returns the path of a playlist's page, `/@owner/playlists/<slug>`. */

View File

@@ -20,6 +20,10 @@ Deno.test("builders: one hierarchy, default views bare", () => {
const item = { ownerUsername: "bob", slug: "never-gonna" };
assertEquals(dumpPath(item), "/@bob/dumps/never-gonna");
assertEquals(playlistPath(item), "/@bob/playlists/never-gonna");
assertEquals(
dumpPath({ ownerUsername: "bob", slug: "a-b" }, "edit"),
"/@bob/dumps/a-b/edit",
);
assertEquals(feedPath(), "/");
assertEquals(feedPath(undefined, "hot"), "/hot");
assertEquals(feedPath("music"), "/music");

View File

@@ -8,7 +8,7 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import type { Wire } from "../../../shared/api.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
@@ -45,7 +45,7 @@ function useFollowList<T>(
view: FollowView,
deserialize: (wire: Wire<T>) => T,
) {
const url = `${API_URL}/api/users/${userId}/follow/${view}`;
const url = userApiUrl({ id: userId }, "follow", view);
const paged = usePagedList<T>(
url,
useCallback(

View File

@@ -7,7 +7,7 @@
*/
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
@@ -42,7 +42,7 @@ export function ProfileManageTab(
async function rename(username: string): Promise<string> {
const renamed = deserializePublicUser(
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/users/${user.id}/username`, {
await authFetch(userApiUrl(user, "username"), {
method: "PATCH",
body: JSON.stringify({ username }),
}),

View File

@@ -10,6 +10,7 @@ import { useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
@@ -93,7 +94,7 @@ function PlaylistList(
}`,
hydratePlaylist,
);
const url = `${API_URL}/api/users/${user.id}/playlists${
const url = `${userApiUrl(user, "playlists")}${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
}`;
const { state, setItems: setAllItems, loadMore } = usePagedList<Playlist>(

View File

@@ -8,7 +8,7 @@
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, Role } from "../../../shared/user.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
@@ -65,7 +65,7 @@ export function RoleSwitcher(
setSaving(true);
setError(null);
try {
const res = await authFetch(`${API_URL}/api/users/${userId}/role`, {
const res = await authFetch(userApiUrl({ id: userId }, "role"), {
method: "PATCH",
body: JSON.stringify({ role }),
});

View File

@@ -6,7 +6,7 @@
*/
import { useContext, useState } from "react";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
/**
@@ -51,8 +51,8 @@ export function Avatar(
if (effectiveHasAvatar && !imgFailed) {
const src = effectiveVersion
? `${API_URL}/api/users/${userId}/avatar?v=${effectiveVersion}`
: `${API_URL}/api/users/${userId}/avatar`;
? `${userApiUrl({ id: userId }, "avatar")}?v=${effectiveVersion}`
: userApiUrl({ id: userId }, "avatar");
return (
<img
src={src}

View File

@@ -7,7 +7,7 @@
import { useCallback } from "react";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import { pageUrl, readPage } from "../../utils/feed/pagedList.ts";
import type { Dump } from "../../../shared/dump.ts";
import {
@@ -71,7 +71,7 @@ export function useUserDumpFeed(
hydrateDump,
);
const url = `${API_URL}/api/users/${userId}/${list}${
const url = `${userApiUrl({ id: userId }, list)}${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
}`;
const paged = usePagedList<Dump>(

View File

@@ -28,7 +28,7 @@ import {
deserializePlaylistWithDumps,
} from "../../utils/shared/deserialize.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
import { isAt, playlistUrl } from "../../utils/shared/urls.ts";
import { isAt, playlistUrl, userApiUrl } from "../../utils/shared/urls.ts";
import { useCanonicalPath } from "../shared/useCanonicalPath.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useWS } from "../ws/useWS.ts";
@@ -124,7 +124,8 @@ export function usePlaylist(
`${target.username}/${target.slug}:${user?.id ?? "guest"}`,
(signal) =>
authFetch(
`${API_URL}/api/users/@${target.username}/playlists/${target.slug}`,
// The key above is falsy, and nothing fetched, without both.
userApiUrl({ username: target.username! }, "playlists", target.slug!),
{ signal },
)
.then(expectOk<Wire<PlaylistWithDumps>>)

View File

@@ -9,7 +9,12 @@ import { useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { dumpThumbnailUrl, dumpUrl, isAt } from "../utils/shared/urls.ts";
import {
dumpThumbnailUrl,
dumpUrl,
isAt,
userApiUrl,
} from "../utils/shared/urls.ts";
import { can } from "../utils/auth/permissions.ts";
import { CategoryChips } from "../components/category/CategoryChips.tsx";
import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx";
@@ -132,7 +137,7 @@ export function Dump(): React.JSX.Element {
const controller = new AbortController();
if (preloaded) {
fetch(`${API_URL}/api/users/${preloaded.userId}`, {
fetch(userApiUrl({ id: preloaded.userId }), {
signal: controller.signal,
})
.then((r) => r.json())
@@ -148,7 +153,7 @@ export function Dump(): React.JSX.Element {
setOp(null);
try {
const res = await authFetch(
`${API_URL}/api/users/@${username}/dumps/${slug}`,
userApiUrl({ username }, "dumps", slug),
{ cache: "no-store", signal: controller.signal },
);
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
@@ -158,7 +163,7 @@ export function Dump(): React.JSX.Element {
const dump: Dump = deserializeDump(apiResponse.data);
setDumpState({ status: "loaded", dump });
fetch(`${API_URL}/api/users/${dump.userId}`, {
fetch(userApiUrl({ id: dump.userId }), {
signal: controller.signal,
})
.then((r) => r.json())
@@ -544,7 +549,7 @@ export function Dump(): React.JSX.Element {
<div className="dump-actions">
{canEdit && (
<Link to={`${dumpUrl(dump)}/edit`}>
<Link to={dumpUrl(dump, "edit")}>
<Trans>Edit</Trans>
</Link>
)}

View File

@@ -30,6 +30,7 @@ import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
userApiUrl,
} from "../utils/shared/urls.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
@@ -92,7 +93,7 @@ export function DumpEdit(): React.JSX.Element {
const [deleteError, setDeleteError] = useState<string | null>(null);
useCanonicalPath(
state.status === "loaded" ? `${dumpUrl(state.dump)}/edit` : null,
state.status === "loaded" ? dumpUrl(state.dump, "edit") : null,
);
useEffect(() => {
@@ -102,7 +103,7 @@ export function DumpEdit(): React.JSX.Element {
setState({ status: "loading" });
try {
const res = await fetch(
`${API_URL}/api/users/@${username}/dumps/${slug}`,
userApiUrl({ username }, "dumps", slug),
{
cache: "no-store",
headers: token ? { Authorization: `Bearer ${token}` } : {},

View File

@@ -8,7 +8,7 @@
import { useLocation, useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import { userApiUrl } from "../utils/shared/urls.ts";
import type { Invitee } from "../../shared/user.ts";
import type { Wire } from "../../shared/api.ts";
import {
@@ -91,7 +91,7 @@ export function UserPublicProfile(): React.JSX.Element {
const invitees = useFetchState<Invitee[]>(
user?.id,
(signal) =>
fetch(`${API_URL}/api/users/${user!.id}/invitees`, { signal })
fetch(userApiUrl(user!, "invitees"), { signal })
.then((res) => dataOr<Wire<Invitee>[]>(res, []))
.then((entries) => entries.map(deserializeInvitee)),
{ enabled: tab === "invitees" },

View File

@@ -9,6 +9,7 @@
import { API_URL } from "../../config/api.ts";
import { splitHandle } from "../../../shared/validation.ts";
import { userApiUrl } from "../shared/urls.ts";
/**
* Checks whether `host`, as a handle writes it, is this instance's: its API's,
@@ -38,5 +39,5 @@ export function homeHost(handle: string): string | undefined {
export function homeUrl(handle: string, path?: string): string | null {
if (!homeHost(handle)) return null;
const query = path ? `?path=${encodeURIComponent(path)}` : "";
return `${API_URL}/api/users/@${encodeURIComponent(handle)}/home${query}`;
return `${userApiUrl({ username: handle }, "home")}${query}`;
}

View File

@@ -4,11 +4,11 @@
* @module
*/
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
import { expectOk } from "./api.ts";
import { deserializePublicUser } from "./deserialize.ts";
import { userApiUrl } from "./urls.ts";
/**
* Returns `GET /api/users/@:username`, for the profile, which fails as a
@@ -24,10 +24,7 @@ export async function fetchPublicUser(
username: string,
signal: AbortSignal,
): Promise<PublicUser> {
const res = await fetch(
`${API_URL}/api/users/@${encodeURIComponent(username)}`,
{ signal },
);
const res = await fetch(userApiUrl({ username }), { signal });
if (res.status === 404) throw new Error("User not found");
return deserializePublicUser(await expectOk<Wire<PublicUser>>(res));
}

View File

@@ -98,11 +98,12 @@ export function normalizeUrl(input: string): string {
}
/**
* Returns the path of the page of `dump`, `/@owner/dumps/<slug>`. Its owner's
* name and its slug come with every dump the API sends.
* Returns the path of the page of `dump`, `/@owner/dumps/<slug>`, or of its
* editor (`dumpPath`). Its owner's name and its slug come with every dump the
* API sends.
*/
export function dumpUrl(dump: OwnedItem): string {
return dumpPath(dump);
export function dumpUrl(dump: OwnedItem, view?: "edit"): string {
return dumpPath(dump, view);
}
/**
@@ -119,6 +120,24 @@ export function isAt(
item.ownerUsername.toLowerCase() === username?.toLowerCase();
}
/**
* Returns the URL of `/api/users/:key`, or of what is under it, `segments`
* such as `"dumps", slug`, each encoded: every request about a user takes it
* from here. The key is `user`'s id, or, when the caller has only the name a
* page's path gives, `@` and that handle, which the API resolves as the page
* would, a former name or a remote user's included.
*/
export function userApiUrl(
user: { id: string } | { username: string },
...segments: string[]
): string {
const key = "id" in user
? encodeURIComponent(user.id)
: `@${encodeURIComponent(user.username)}`;
return [`${API_URL}/api/users`, key, ...segments.map(encodeURIComponent)]
.join("/");
}
/** Returns the path of the page of `playlist`, `/@owner/playlists/<slug>`. */
export function playlistUrl(playlist: OwnedItem): string {
return playlistPath(playlist);