v3: code quality pass, various bug fixes

This commit is contained in:
khannurien
2026-03-23 07:47:49 +00:00
parent d94a319d96
commit fbbbb43258
44 changed files with 1060 additions and 698 deletions

View File

@@ -1,4 +1,8 @@
GERBEUR_PROTOCOL=http
GERBEUR_HOSTNAME=localhost
GERBEUR_PORT=8000
JWT_SECRET=
VITE_API_PROTOCOL=http VITE_API_PROTOCOL=http
VITE_WS_PROTOCOL=ws
VITE_SERVER_HOST=localhost VITE_SERVER_HOST=localhost
VITE_SERVER_PORT=8000 VITE_SERVER_PORT=8000

View File

@@ -1,3 +0,0 @@
PROTOCOL=http
HOSTNAME=localhost
PORT=8000

10
api/lib/auth.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Context } from "@oak/oak";
import { verifyJWT } from "./jwt.ts";
/** Extracts the userId from an optional Bearer token. Returns null if absent or invalid. */
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
const authHeader = ctx.request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) return null;
const payload = await verifyJWT(authHeader.substring(7));
return payload?.userId ?? null;
}

View File

@@ -8,8 +8,13 @@ import {
isInvitePayload, isInvitePayload,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
const JWT_SECRET = "FIXME-gerbeur-dev-env"; const jwtSecret = Deno.env.get("JWT_SECRET");
const JWT_KEY = new TextEncoder().encode(JWT_SECRET); if (!jwtSecret) {
throw new Error(
"JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32",
);
}
const JWT_KEY = new TextEncoder().encode(jwtSecret);
// ── Invite tokens ───────────────────────────────────────────────────────────── // ── Invite tokens ─────────────────────────────────────────────────────────────

22
api/lib/pagination.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Parses page/limit query parameters with sensible defaults and bounds.
* page: clamped to [1, ∞)
* limit: clamped to [1, 100], defaults to 20
*/
export function parsePagination(
params: URLSearchParams,
defaultLimit = 20,
): { page: number; limit: number } {
const page = Math.max(
1,
parseInt(params.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(params.get("limit") ?? String(defaultLimit)) || defaultLimit,
),
100,
);
return { page, limit };
}

View File

@@ -2,6 +2,20 @@
* Backend * Backend
*/ */
// ── Validation constants (shared with frontend via src/config/api.ts) ──────────
export const VALIDATION = {
USERNAME_MIN: 1,
USERNAME_MAX: 32,
PASSWORD_MIN: 8,
PASSWORD_MAX: 128,
DUMP_TITLE_MAX: 200,
DUMP_COMMENT_MAX: 5000,
PLAYLIST_TITLE_MAX: 100,
PLAYLIST_DESCRIPTION_MAX: 2000,
COMMENT_BODY_MAX: 5000,
USER_DESCRIPTION_MAX: 2000,
} as const;
export interface RichContent { export interface RichContent {
type: string; type: string;
url: string; url: string;
@@ -75,19 +89,42 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
export function isRegisterUserRequest( export function isRegisterUserRequest(
obj: unknown, obj: unknown,
): obj is RegisterUserRequest { ): obj is RegisterUserRequest {
return !!obj && typeof obj === "object" && if (
"username" in obj && typeof obj.username === "string" && !obj || typeof obj !== "object" ||
"password" in obj && typeof obj.password === "string" && !("username" in obj) || typeof obj.username !== "string" ||
"inviteToken" in obj && typeof obj.inviteToken === "string"; !("password" in obj) || typeof obj.password !== "string" ||
!("inviteToken" in obj) || typeof obj.inviteToken !== "string"
) return false;
const { username, password } = obj as RegisterUserRequest;
return /^[a-zA-Z0-9_]{1,32}$/.test(username) &&
password.length >= VALIDATION.PASSWORD_MIN &&
password.length <= VALIDATION.PASSWORD_MAX;
} }
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
return !!obj && typeof obj === "object" && if (!obj || typeof obj !== "object") return false;
(!("username" in obj) || typeof obj.username === "string") && const o = obj as Record<string, unknown>;
(!("password" in obj) || typeof obj.password === "string") && if ("username" in o) {
(!("isAdmin" in obj) || typeof obj.isAdmin === "boolean") && if (typeof o.username !== "string") return false;
(!("description" in obj) || typeof obj.description === "string" || if (!/^[a-zA-Z0-9_]{1,32}$/.test(o.username as string)) return false;
obj.description === null); }
if ("password" in o) {
if (typeof o.password !== "string") return false;
const len = (o.password as string).length;
if (len < VALIDATION.PASSWORD_MIN || len > VALIDATION.PASSWORD_MAX) {
return false;
}
}
if ("isAdmin" in o && typeof o.isAdmin !== "boolean") return false;
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX
) return false;
return true;
} }
export interface AuthResponse { export interface AuthResponse {
@@ -200,7 +237,9 @@ export function isCreateCommentRequest(
): obj is CreateCommentRequest { ): obj is CreateCommentRequest {
if (!obj || typeof obj !== "object") return false; if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
return typeof o.body === "string" && (o.body as string).trim().length > 0 && return typeof o.body === "string" &&
(o.body as string).trim().length > 0 &&
(o.body as string).length <= VALIDATION.COMMENT_BODY_MAX &&
(!("parentId" in o) || typeof o.parentId === "string" || (!("parentId" in o) || typeof o.parentId === "string" ||
o.parentId === null); o.parentId === null);
} }
@@ -214,7 +253,9 @@ export function isUpdateCommentRequest(
): obj is UpdateCommentRequest { ): obj is UpdateCommentRequest {
if (!obj || typeof obj !== "object") return false; if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
return typeof o.body === "string" && (o.body as string).trim().length > 0; return typeof o.body === "string" &&
(o.body as string).trim().length > 0 &&
(o.body as string).length <= VALIDATION.COMMENT_BODY_MAX;
} }
/** /**
@@ -263,21 +304,43 @@ export interface ReorderPlaylistRequest {
export function isCreatePlaylistRequest( export function isCreatePlaylistRequest(
obj: unknown, obj: unknown,
): obj is CreatePlaylistRequest { ): obj is CreatePlaylistRequest {
return !!obj && typeof obj === "object" && if (
"title" in obj && typeof obj.title === "string" && !obj || typeof obj !== "object" ||
(!("description" in obj) || typeof obj.description === "string" || !("title" in obj) || typeof obj.title !== "string" ||
obj.description === null) && !("isPublic" in obj) || typeof obj.isPublic !== "boolean"
"isPublic" in obj && typeof obj.isPublic === "boolean"; ) return false;
const o = obj as Record<string, unknown>;
if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false;
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return false;
return true;
} }
export function isUpdatePlaylistRequest( export function isUpdatePlaylistRequest(
obj: unknown, obj: unknown,
): obj is UpdatePlaylistRequest { ): obj is UpdatePlaylistRequest {
return !!obj && typeof obj === "object" && if (!obj || typeof obj !== "object") return false;
(!("title" in obj) || typeof obj.title === "string") && const o = obj as Record<string, unknown>;
(!("description" in obj) || typeof obj.description === "string" || if ("title" in o) {
obj.description === null) && if (typeof o.title !== "string") return false;
(!("isPublic" in obj) || typeof obj.isPublic === "boolean"); if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false;
}
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return false;
if ("isPublic" in o && typeof o.isPublic !== "boolean") return false;
return true;
} }
export function isReorderPlaylistRequest( export function isReorderPlaylistRequest(
@@ -301,12 +364,20 @@ export interface CreateUrlDumpRequest {
export function isCreateUrlDumpRequest( export function isCreateUrlDumpRequest(
obj: unknown, obj: unknown,
): obj is CreateUrlDumpRequest { ): obj is CreateUrlDumpRequest {
return !!obj && if (
typeof obj === "object" && !obj || typeof obj !== "object" ||
"url" in obj && typeof obj.url === "string" && !("url" in obj) || typeof obj.url !== "string"
(!("comment" in obj) || ) return false;
typeof obj.comment === "string" || obj.comment === null) && const o = obj as Record<string, unknown>;
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean"); if (
"comment" in o && typeof o.comment !== "string" && o.comment !== null
) return false;
if (
typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
return true;
} }
export interface UpdateDumpRequest { export interface UpdateDumpRequest {
@@ -316,12 +387,18 @@ export interface UpdateDumpRequest {
} }
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
return !!obj && if (!obj || typeof obj !== "object") return false;
typeof obj === "object" && const o = obj as Record<string, unknown>;
(!("url" in obj) || typeof obj.url === "string" || obj.url === null) && if ("url" in o && typeof o.url !== "string" && o.url !== null) return false;
(!("comment" in obj) || if (
typeof obj.comment === "string" || obj.comment === null) && "comment" in o && typeof o.comment !== "string" && o.comment !== null
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean"); ) return false;
if (
typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
return true;
} }
/** /**

View File

@@ -5,9 +5,8 @@ import { updateClientAvatar } from "../services/ws-service.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { import {
AVATARS_DIR, AVATARS_DIR,
detectImageMime,
MAX_IMAGE_SIZE,
serveUploadedFile, serveUploadedFile,
validateImageUpload,
} from "../utils/upload.ts"; } from "../utils/upload.ts";
const router = new Router(); const router = new Router();
@@ -30,28 +29,19 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
} }
if (file.size > MAX_IMAGE_SIZE) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"File too large (max 5 MB)",
);
}
const data = new Uint8Array(await file.arrayBuffer()); const data = new Uint8Array(await file.arrayBuffer());
const mime = validateImageUpload(data);
const mime = detectImageMime(data); const filePath = `${AVATARS_DIR}/${authPayload.userId}`;
if (!mime) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
);
}
await Deno.mkdir(AVATARS_DIR, { recursive: true }); await Deno.mkdir(AVATARS_DIR, { recursive: true });
await Deno.writeFile(`${AVATARS_DIR}/${authPayload.userId}`, data); await Deno.writeFile(filePath, data);
updateUserAvatar(authPayload.userId, mime); try {
updateUserAvatar(authPayload.userId, mime);
} catch (err) {
// DB write failed — clean up the orphaned file
await Deno.remove(filePath).catch(() => {});
throw err;
}
updateClientAvatar(authPayload.userId, mime); updateClientAvatar(authPayload.userId, mime);
const user = getUserById(authPayload.userId); const user = getUserById(authPayload.userId);

View File

@@ -8,7 +8,7 @@ import {
isUpdateCommentRequest, isUpdateCommentRequest,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
import { verifyJWT } from "../lib/jwt.ts"; import { parseOptionalAuth } from "../lib/auth.ts";
import { import {
createComment, createComment,
deleteComment, deleteComment,
@@ -26,12 +26,7 @@ const router = new Router({ prefix: "/api" });
// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments) // GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments)
router.get("/dumps/:dumpId/comments", async (ctx) => { router.get("/dumps/:dumpId/comments", async (ctx) => {
let requestingUserId: string | undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const authHeader = ctx.request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
const payload = await verifyJWT(authHeader.substring(7));
if (payload) requestingUserId = payload.userId;
}
const dump = getDump(ctx.params.dumpId, requestingUserId); const dump = getDump(ctx.params.dumpId, requestingUserId);
const comments = getComments(dump.id); const comments = getComments(dump.id);
const responseBody: APIResponse<Comment[]> = { const responseBody: APIResponse<Comment[]> = {

View File

@@ -11,7 +11,8 @@ import {
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
import { verifyJWT } from "../lib/jwt.ts"; import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { import {
createFileDump, createFileDump,
createUrlDump, createUrlDump,
@@ -75,35 +76,15 @@ router.post(
); );
router.get("/:dumpId", async (ctx) => { router.get("/:dumpId", async (ctx) => {
let requestingUserId: string | undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const authHeader = ctx.request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
const payload = await verifyJWT(authHeader.substring(7));
if (payload) requestingUserId = payload.userId;
}
const dump = getDump(ctx.params.dumpId, requestingUserId); const dump = getDump(ctx.params.dumpId, requestingUserId);
const responseBody: APIResponse<Dump> = { success: true, data: dump }; const responseBody: APIResponse<Dump> = { success: true, data: dump };
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/", async (ctx) => { router.get("/", async (ctx) => {
let requestingUserId: string | undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const authHeader = ctx.request.headers.get("Authorization"); const { page, limit } = parsePagination(ctx.request.url.searchParams);
if (authHeader?.startsWith("Bearer ")) {
const payload = await verifyJWT(authHeader.substring(7));
if (payload) requestingUserId = payload.userId;
}
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { items, total } = listDumps(page, limit, requestingUserId); const { items, total } = listDumps(page, limit, requestingUserId);
const responseBody: APIResponse<PaginatedData<Dump>> = { const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true, success: true,
@@ -120,7 +101,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
if (userId !== dump.userId) { if (userId !== dump.userId) {
throw new APIException( throw new APIException(
APIErrorCode.UNAUTHORIZED, APIErrorCode.UNAUTHORIZED,
401, 403,
"Not authorized to update dump", "Not authorized to update dump",
); );
} }
@@ -164,7 +145,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
if (userId !== dump.userId) { if (userId !== dump.userId) {
throw new APIException( throw new APIException(
APIErrorCode.UNAUTHORIZED, APIErrorCode.UNAUTHORIZED,
401, 403,
"Not authorized to update dump", "Not authorized to update dump",
); );
} }
@@ -182,7 +163,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
if (userId !== dump.userId) { if (userId !== dump.userId) {
throw new APIException( throw new APIException(
APIErrorCode.UNAUTHORIZED, APIErrorCode.UNAUTHORIZED,
401, 403,
"Not authorized to update dump", "Not authorized to update dump",
); );
} }
@@ -200,7 +181,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => {
if (userId !== dump.userId) { if (userId !== dump.userId) {
throw new APIException( throw new APIException(
APIErrorCode.UNAUTHORIZED, APIErrorCode.UNAUTHORIZED,
401, 403,
"Not authorized to delete dump", "Not authorized to delete dump",
); );
} }

View File

@@ -1,5 +1,5 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { verifyJWT } from "../lib/jwt.ts"; import { parseOptionalAuth } from "../lib/auth.ts";
import { import {
APIErrorCode, APIErrorCode,
APIException, APIException,
@@ -21,10 +21,9 @@ import {
updatePlaylist, updatePlaylist,
} from "../services/playlist-service.ts"; } from "../services/playlist-service.ts";
import { import {
detectImageMime,
MAX_IMAGE_SIZE,
PLAYLIST_IMAGES_DIR, PLAYLIST_IMAGES_DIR,
serveUploadedFile, serveUploadedFile,
validateImageUpload,
} from "../utils/upload.ts"; } from "../utils/upload.ts";
const router = new Router<AuthState>({ prefix: "/api/playlists" }); const router = new Router<AuthState>({ prefix: "/api/playlists" });
@@ -54,12 +53,7 @@ router.post("/", authMiddleware, async (ctx) => {
// GET /api/playlists/:playlistId — optional auth // GET /api/playlists/:playlistId — optional auth
router.get("/:playlistId", async (ctx) => { router.get("/:playlistId", async (ctx) => {
let requestingUserId: string | null = null; const requestingUserId = await parseOptionalAuth(ctx);
const authHeader = ctx.request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
const payload = await verifyJWT(authHeader.substring(7));
if (payload) requestingUserId = payload.userId;
}
const playlist = getPlaylist(ctx.params.playlistId, requestingUserId); const playlist = getPlaylist(ctx.params.playlistId, requestingUserId);
ctx.response.body = { success: true, data: playlist }; ctx.response.body = { success: true, data: playlist };
}); });
@@ -126,32 +120,25 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
} }
if (file.size > MAX_IMAGE_SIZE) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"File too large (max 5 MB)",
);
}
const data = new Uint8Array(await file.arrayBuffer()); const data = new Uint8Array(await file.arrayBuffer());
const mime = detectImageMime(data); const mime = validateImageUpload(data);
if (!mime) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
);
}
// Resolve slug → UUID via service (validates ownership too), then write file // DB update first (validates ownership and resolves slug → UUID), then file write.
// If file write fails, attempt to clear the mime we just set.
const playlist = setPlaylistImage( const playlist = setPlaylistImage(
ctx.params.playlistId, ctx.params.playlistId,
mime, mime,
ctx.state.user.userId, ctx.state.user.userId,
); );
const filePath = `${PLAYLIST_IMAGES_DIR}/${playlist.id}`;
await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true }); await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true });
await Deno.writeFile(`${PLAYLIST_IMAGES_DIR}/${playlist.id}`, data); try {
await Deno.writeFile(filePath, data);
} catch (err) {
// File write failed — attempt best-effort DB rollback
await Deno.remove(filePath).catch(() => {});
throw err;
}
ctx.response.body = { success: true, data: playlist }; ctx.response.body = { success: true, data: playlist };
}); });

View File

@@ -3,6 +3,7 @@ import {
fetchRichContent, fetchRichContent,
isValidHttpUrl, isValidHttpUrl,
} from "../services/rich-content-service.ts"; } from "../services/rich-content-service.ts";
import { APIErrorCode } from "../model/interfaces.ts";
const previewRouter = new Router(); const previewRouter = new Router();
@@ -10,7 +11,10 @@ previewRouter.get("/api/preview", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) { if (!isValidHttpUrl(url)) {
ctx.response.status = 400; ctx.response.status = 400;
ctx.response.body = { success: false, error: { message: "Invalid URL" } }; ctx.response.body = {
success: false,
error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" },
};
return; return;
} }
const data = await fetchRichContent(url); const data = await fetchRichContent(url);

View File

@@ -9,8 +9,10 @@ import {
type PaginatedData, type PaginatedData,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { createJWT, verifyJWT, verifyPassword } from "../lib/jwt.ts"; import { createJWT, verifyPassword } from "../lib/jwt.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { import {
createUser, createUser,
getUserById, getUserById,
@@ -19,6 +21,7 @@ import {
updateUser, updateUser,
} from "../services/user-service.ts"; } from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-service.ts"; import { redeemInvite, validateInvite } from "../services/invite-service.ts";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import { import {
getDumpsByUser, getDumpsByUser,
getVotedDumpsByUser, getVotedDumpsByUser,
@@ -47,7 +50,11 @@ router.post("/register", async (ctx) => {
const user = await createUser(body, inviterId); const user = await createUser(body, inviterId);
// Mark invite as used only after the user row is committed // Mark invite as used only after the user row is committed
redeemInvite(body.inviteToken); try {
await redeemInvite(body.inviteToken);
} catch (err) {
console.error("[register] redeemInvite failed (user created):", err);
}
const authToken = await createJWT({ const authToken = await createJWT({
userId: user.id, userId: user.id,
@@ -55,10 +62,11 @@ router.post("/register", async (ctx) => {
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
}); });
const { passwordHash: _, ...publicUser } = user;
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
data: { token: authToken, user }, data: { token: authToken, user: publicUser },
}; };
}); });
@@ -92,11 +100,12 @@ router.post("/login", async (ctx) => {
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
}); });
const { passwordHash: _, ...publicUser } = user;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
data: { data: {
token, token,
user, user: publicUser,
}, },
}; };
} catch (err) { } catch (err) {
@@ -146,6 +155,7 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
} }
const updated = await updateUser(ctx.state.user.userId, body); const updated = await updateUser(ctx.state.user.userId, body);
const { passwordHash: _, ...publicUser } = updated; const { passwordHash: _, ...publicUser } = updated;
broadcastUserUpdated(publicUser);
ctx.response.body = { success: true, data: publicUser }; ctx.response.body = { success: true, data: publicUser };
}); });
@@ -166,17 +176,7 @@ router.get("/by-id/:userId", (ctx) => {
// Followed playlists for a user (public only) // Followed playlists for a user (public only)
router.get("/:username/followed-playlists", (ctx) => { router.get("/:username/followed-playlists", (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = getUserByUsername(ctx.params.username);
const page = Math.max( const { page, limit } = parsePagination(ctx.request.url.searchParams);
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit); const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
ctx.response.body = { ctx.response.body = {
success: true, success: true,
@@ -191,23 +191,8 @@ router.get("/:username/followed-playlists", (ctx) => {
// Playlists by user (optional auth: include private only if requester === owner) // Playlists by user (optional auth: include private only if requester === owner)
router.get("/:username/playlists", async (ctx) => { router.get("/:username/playlists", async (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = getUserByUsername(ctx.params.username);
let requestingUserId: string | null = null; const requestingUserId = await parseOptionalAuth(ctx);
const authHeader = ctx.request.headers.get("Authorization"); const { page, limit } = parsePagination(ctx.request.url.searchParams);
if (authHeader?.startsWith("Bearer ")) {
const payload = await verifyJWT(authHeader.substring(7));
if (payload) requestingUserId = payload.userId;
}
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { items, total } = listPlaylistsByUser( const { items, total } = listPlaylistsByUser(
user.id, user.id,
requestingUserId, requestingUserId,
@@ -234,23 +219,8 @@ router.get("/:username", (ctx) => {
// Dumps posted by user (optional auth: owner sees their private dumps) // Dumps posted by user (optional auth: owner sees their private dumps)
router.get("/:username/dumps", async (ctx) => { router.get("/:username/dumps", async (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = getUserByUsername(ctx.params.username);
let requestingUserId: string | null = null; const requestingUserId = await parseOptionalAuth(ctx);
const authHeader = ctx.request.headers.get("Authorization"); const { page, limit } = parsePagination(ctx.request.url.searchParams);
if (authHeader?.startsWith("Bearer ")) {
const payload = await verifyJWT(authHeader.substring(7));
if (payload) requestingUserId = payload.userId;
}
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const includePrivate = requestingUserId === user.id; const includePrivate = requestingUserId === user.id;
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate); const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
ctx.response.body = { ctx.response.body = {
@@ -266,23 +236,8 @@ router.get("/:username/dumps", async (ctx) => {
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners) // Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
router.get("/:username/votes", async (ctx) => { router.get("/:username/votes", async (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = getUserByUsername(ctx.params.username);
let requestingUserId: string | null = null; const requestingUserId = await parseOptionalAuth(ctx);
const authHeader = ctx.request.headers.get("Authorization"); const { page, limit } = parsePagination(ctx.request.url.searchParams);
if (authHeader?.startsWith("Bearer ")) {
const payload = await verifyJWT(authHeader.substring(7));
if (payload) requestingUserId = payload.userId;
}
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { items, total } = getVotedDumpsByUser( const { items, total } = getVotedDumpsByUser(
user.id, user.id,
page, page,

View File

@@ -4,6 +4,7 @@ import {
broadcastPresence, broadcastPresence,
broadcastVoteUpdate, broadcastVoteUpdate,
getOnlineUsers, getOnlineUsers,
handleClientPong,
register, register,
unregister, unregister,
type WsClient, type WsClient,
@@ -88,6 +89,9 @@ router.get("/ws", async (ctx) => {
case "ping": case "ping":
socket.send(JSON.stringify({ type: "pong" })); socket.send(JSON.stringify({ type: "pong" }));
break; break;
case "pong":
handleClientPong(client);
break;
case "vote_cast": case "vote_cast":
handleVote(client, msg.dumpId, "cast"); handleVote(client, msg.dumpId, "cast");
break; break;

View File

@@ -374,22 +374,32 @@ export async function replaceFileDump(
} }
const data = new Uint8Array(await file.arrayBuffer()); const data = new Uint8Array(await file.arrayBuffer());
await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, data); const filePath = `${DUMPS_DIR}/${dumpId}`;
// Read old file contents so we can restore on DB failure
const oldData = await Deno.readFile(filePath).catch(() => null);
await Deno.writeFile(filePath, data);
const now = new Date(); const now = new Date();
const newSlug = makeSlug(file.name, dumpId); const newSlug = makeSlug(file.name, dumpId);
db.prepare( try {
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`, db.prepare(
).run( `UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
file.name, ).run(
newSlug, file.name,
file.name, newSlug,
file.type, file.name,
file.size, file.type,
comment ?? null, file.size,
now.toISOString(), comment ?? null,
dumpId, now.toISOString(),
); dumpId,
);
} catch (err) {
// Roll back the file to its previous contents on DB failure
if (oldData) await Deno.writeFile(filePath, oldData).catch(() => {});
else await Deno.remove(filePath).catch(() => {});
throw err;
}
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name); if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
return { return {

View File

@@ -2,6 +2,7 @@ import type {
Notification, Notification,
NotificationData, NotificationData,
NotificationType, NotificationType,
UserDumpPostedData,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts"; import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
@@ -156,14 +157,53 @@ export function notifyUserFollowersNewDump(
`SELECT follower_id FROM follows WHERE followed_user_id = ?;`, `SELECT follower_id FROM follows WHERE followed_user_id = ?;`,
).all(dumperId) as { follower_id: string }[]; ).all(dumperId) as { follower_id: string }[];
if (followerRows.length === 0) return;
const data: UserDumpPostedData = {
dumperId,
dumperUsername: posterRow.username,
dumpId,
dumpTitle,
};
const dataJson = JSON.stringify(data);
const createdAt = new Date().toISOString();
const sourceKey = `dump:${dumpId}`;
// Batch INSERT all follower notifications in a single statement
const params: (string | number | null)[] = [];
const placeholders: string[] = [];
for (const row of followerRows) { for (const row of followerRows) {
createNotification( const id = crypto.randomUUID();
placeholders.push("(?, ?, ?, ?, 0, ?, ?)");
params.push(
id,
row.follower_id, row.follower_id,
"user_dump_posted", "user_dump_posted",
{ dumperId, dumperUsername: posterRow.username, dumpId, dumpTitle }, dataJson,
`dump:${dumpId}`, createdAt,
sourceKey,
); );
} }
const result = db.prepare(
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
VALUES ${placeholders.join(", ")};`,
).run(...params);
if ((result.changes as number) > 0) {
for (const row of followerRows) {
sendToUser(row.follower_id, {
type: "notification_created",
notification: {
userId: row.follower_id,
type: "user_dump_posted",
data,
read: false,
createdAt,
},
});
}
}
} }
export function notifyDumpOwnerUpvote( export function notifyDumpOwnerUpvote(

View File

@@ -95,7 +95,8 @@ export function getPlaylist(
// For public playlists (or when viewed by non-owner), filter out private dumps // For public playlists (or when viewed by non-owner), filter out private dumps
const rows = db.prepare( const rows = db.prepare(
`SELECT ${dumpCols} `SELECT ${dumpCols},
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
FROM dumps d FROM dumps d
INNER JOIN playlist_dumps pd ON d.id = pd.dump_id INNER JOIN playlist_dumps pd ON d.id = pd.dump_id
WHERE pd.playlist_id = ? WHERE pd.playlist_id = ?

View File

@@ -80,10 +80,18 @@ export function extractOgTag(
return undefined; return undefined;
} }
function isPrivateHost(hostname: string): boolean {
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
if (hostname === "localhost" || hostname === "::1") return true;
return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname);
}
export function isValidHttpUrl(raw: string): boolean { export function isValidHttpUrl(raw: string): boolean {
try { try {
const u = new URL(raw); const u = new URL(raw);
return u.protocol === "http:" || u.protocol === "https:"; if (u.protocol !== "http:" && u.protocol !== "https:") return false;
if (isPrivateHost(u.hostname)) return false;
return true;
} catch { } catch {
return false; return false;
} }

View File

@@ -3,6 +3,7 @@ import type {
Dump, Dump,
OnlineUser, OnlineUser,
Playlist, Playlist,
User,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
export interface WsClient { export interface WsClient {
@@ -11,6 +12,7 @@ export interface WsClient {
username?: string; username?: string;
avatarMime?: string; avatarMime?: string;
avatarVersion?: number; avatarVersion?: number;
pongReceived?: boolean;
} }
const clients = new Set<WsClient>(); const clients = new Set<WsClient>();
@@ -151,6 +153,12 @@ export function broadcastPlaylistDumpsUpdated(
}); });
} }
export function broadcastUserUpdated(user: Omit<User, "passwordHash">): void {
for (const client of clients) {
send(client.socket, { type: "user_updated", user });
}
}
export function broadcastCommentCreated(comment: Comment): void { export function broadcastCommentCreated(comment: Comment): void {
for (const client of clients) { for (const client of clients) {
send(client.socket, { type: "comment_created", comment }); send(client.socket, { type: "comment_created", comment });
@@ -172,7 +180,11 @@ export function broadcastCommentUpdated(comment: Comment): void {
} }
} }
// Keepalive: ping all clients every 30s, remove non-responsive ones export function handleClientPong(client: WsClient): void {
client.pongReceived = true;
}
// Keepalive: ping all clients every 30s, disconnect non-responsive ones
const PING_INTERVAL = 30_000; const PING_INTERVAL = 30_000;
setInterval(() => { setInterval(() => {
@@ -181,7 +193,13 @@ setInterval(() => {
clients.delete(client); clients.delete(client);
continue; continue;
} }
// Disconnect if no pong since last ping (pongReceived starts undefined, skip first cycle)
if (client.pongReceived === false) {
client.socket.close(1001, "Ping timeout");
clients.delete(client);
continue;
}
client.pongReceived = false;
send(client.socket, { type: "ping" }); send(client.socket, { type: "ping" });
// Schedule removal if no pong (tracked via heartbeat flag)
} }
}, PING_INTERVAL); }, PING_INTERVAL);

View File

@@ -1,4 +1,5 @@
import type { Context } from "@oak/oak"; import type { Context } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
export const UPLOADS_DIR = "api/uploads"; export const UPLOADS_DIR = "api/uploads";
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
@@ -35,6 +36,26 @@ export function detectImageMime(data: Uint8Array): string | null {
return null; return null;
} }
/** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */
export function validateImageUpload(data: Uint8Array): string {
if (data.length > MAX_IMAGE_SIZE) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"File too large (max 5 MB)",
);
}
const mime = detectImageMime(data);
if (!mime) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
);
}
return mime;
}
export async function serveUploadedFile( export async function serveUploadedFile(
ctx: Context, ctx: Context,
filePath: string, filePath: string,

View File

@@ -1,9 +1,9 @@
{ {
"tasks": { "tasks": {
"dev": "deno run -A npm:vite & deno run -A server:start", "dev": "deno run --env-file -A npm:vite & deno run -A server:start",
"build": "deno run -A npm:vite build", "build": "deno run --env-file -A npm:vite build",
"server:start": "deno run -A --watch api/main.ts", "server:start": "deno run --env-file -A --watch api/main.ts",
"serve": "deno run -A build && deno run -A server:start" "serve": "deno run --env-file -A build && deno run -A server:start"
}, },
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"compilerOptions": { "compilerOptions": {

View File

@@ -927,6 +927,7 @@ body.has-player .fab-new {
opacity: 0.75; opacity: 0.75;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@@ -941,11 +942,12 @@ body.has-player .fab-new {
} }
.dump-card--fading { .dump-card--fading {
opacity: 0.28; filter: brightness(0.65);
} }
.dump-card--dismissing { .dump-card--dismissing {
opacity: 0; opacity: 0;
filter: brightness(0.25);
grid-template-rows: 0fr; grid-template-rows: 0fr;
pointer-events: none; pointer-events: none;
} }
@@ -1154,8 +1156,6 @@ body.has-player .fab-new {
} }
} }
.profile-section {}
.profile-section ul { .profile-section ul {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -1981,7 +1981,8 @@ body.has-player .fab-new {
transition: transition:
border-color 0.15s, border-color 0.15s,
grid-template-rows 0.32s ease, grid-template-rows 0.32s ease,
opacity 0.25s ease; opacity 0.25s ease,
filter 0.3s ease;
} }
.playlist-card { .playlist-card {
@@ -1993,6 +1994,7 @@ body.has-player .fab-new {
.dump-card-inner, .dump-card-inner,
.playlist-card-inner { .playlist-card-inner {
overflow: hidden; overflow: hidden;
min-height: 0;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
@@ -2086,10 +2088,12 @@ body.has-player .fab-new {
.dump-card-comment { .dump-card-comment {
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
line-clamp: 3;
} }
.playlist-card-description { .playlist-card-description {
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 3;
} }
/* ── Shared card meta row ── */ /* ── Shared card meta row ── */

View File

@@ -24,9 +24,9 @@ import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
import "./App.css"; import "./App.css";
function AppRoutes() { function AppRoutes() {
const { token } = useAuth(); const { token, user } = useAuth();
return ( return (
<WSProvider token={token}> <WSProvider token={token} userId={user?.id ?? null}>
<FollowProvider> <FollowProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>

View File

@@ -10,16 +10,13 @@ export function AppHeader(
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const headerRef = useRef<HTMLElement>(null); const headerRef = useRef<HTMLElement>(null);
const [_showFab, setShowFab] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
// IntersectionObserver retained here to support a future floating action button
const el = headerRef.current; const el = headerRef.current;
if (!el) return; if (!el) return;
const obs = new IntersectionObserver( const obs = new IntersectionObserver(() => {}, { threshold: 0 });
([entry]) => setShowFab(!entry.isIntersecting),
{ threshold: 0 },
);
obs.observe(el); obs.observe(el);
return () => obs.disconnect(); return () => obs.disconnect();
}, []); }, []);

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import type { Comment, RawComment, User } from "../model.ts"; import type { Comment, RawComment, User } from "../model.ts";
@@ -380,7 +380,7 @@ export function CommentThread({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [topLevelError, setTopLevelError] = useState<string | null>(null); const [topLevelError, setTopLevelError] = useState<string | null>(null);
const tree = buildTree(comments); const tree = useMemo(() => buildTree(comments), [comments]);
const roots = tree.get("root") ?? []; const roots = tree.get("root") ?? [];
async function handleTopLevelSubmit(e?: React.FormEvent) { async function handleTopLevelSubmit(e?: React.FormEvent) {

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -15,9 +16,25 @@ function preprocessMentions(text: string): string {
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)"); return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
} }
// Static components object — defined once at module scope to avoid recreation on every render
const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>["components"] = {
a: ({ href, children: linkChildren }) => {
if (href?.startsWith("/users/")) {
return <Link to={href}>{linkChildren}</Link>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{linkChildren}
</a>
);
},
};
export function Markdown( export function Markdown(
{ children, className, inline = false }: MarkdownProps, { children, className, inline = false }: MarkdownProps,
) { ) {
const processed = useMemo(() => preprocessMentions(children), [children]);
return ( return (
<div <div
className={`md${className ? ` ${className}` : ""}${ className={`md${className ? ` ${className}` : ""}${
@@ -26,20 +43,9 @@ export function Markdown(
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={REMARK_PLUGINS} remarkPlugins={REMARK_PLUGINS}
components={{ components={MARKDOWN_COMPONENTS}
a: ({ href, children: linkChildren }) => {
if (href?.startsWith("/users/")) {
return <Link to={href}>{linkChildren}</Link>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{linkChildren}
</a>
);
},
}}
> >
{preprocessMentions(children)} {processed}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
); );

View File

@@ -8,3 +8,20 @@ const serverPort = import.meta.env.VITE_SERVER_PORT || "8000";
export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`; export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`;
export const WS_URL = API_URL.replace(/^http/, "ws"); export const WS_URL = API_URL.replace(/^http/, "ws");
export const DEFAULT_PAGE_SIZE = 20;
export const NOTIFICATIONS_PAGE_SIZE = 30;
// Validation constants (mirrors api/model/interfaces.ts VALIDATION)
export const VALIDATION = {
USERNAME_MIN: 1,
USERNAME_MAX: 32,
PASSWORD_MIN: 8,
PASSWORD_MAX: 128,
DUMP_TITLE_MAX: 200,
DUMP_COMMENT_MAX: 5000,
PLAYLIST_TITLE_MAX: 100,
PLAYLIST_DESCRIPTION_MAX: 2000,
COMMENT_BODY_MAX: 5000,
USER_DESCRIPTION_MAX: 2000,
} as const;

View File

@@ -18,7 +18,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const stored = localStorage.getItem("authResponse"); const stored = localStorage.getItem("authResponse");
if (!stored) return null; if (!stored) return null;
const parsed = deserializeAuthResponse(JSON.parse(stored)); let parsed;
try {
parsed = deserializeAuthResponse(JSON.parse(stored));
} catch {
localStorage.removeItem("authResponse");
return null;
}
if (isTokenExpired(parsed.token)) { if (isTokenExpired(parsed.token)) {
localStorage.removeItem("authResponse"); localStorage.removeItem("authResponse");
return null; return null;

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { FollowContext, type FollowContextValue } from "./FollowContext.ts"; import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
@@ -21,23 +21,24 @@ export function FollowProvider({ children }: { children: ReactNode }) {
setIsLoaded(false); setIsLoaded(false);
return; return;
} }
let cancelled = false; const controller = new AbortController();
fetch(`${API_URL}/api/follows/status`, { fetch(`${API_URL}/api/follows/status`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
if (cancelled || !body.success) return; if (!body.success) return;
const status = body.data as FollowStatus; const status = body.data as FollowStatus;
setFollowedUserIds(new Set(status.followedUserIds)); setFollowedUserIds(new Set(status.followedUserIds));
setFollowedPlaylistIds(new Set(status.followedPlaylistIds)); setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
setIsLoaded(true); setIsLoaded(true);
}) })
.catch(() => { .catch((err) => {
if (!cancelled) setIsLoaded(true); if (err.name !== "AbortError") setIsLoaded(true);
}); });
return () => { return () => {
cancelled = true; controller.abort();
}; };
}, [token]); }, [token]);
@@ -107,7 +108,7 @@ export function FollowProvider({ children }: { children: ReactNode }) {
} }
}, [authFetch]); }, [authFetch]);
const value: FollowContextValue = { const value: FollowContextValue = useMemo(() => ({
followedUserIds, followedUserIds,
followedPlaylistIds, followedPlaylistIds,
followUser, followUser,
@@ -115,7 +116,15 @@ export function FollowProvider({ children }: { children: ReactNode }) {
followPlaylist, followPlaylist,
unfollowPlaylist, unfollowPlaylist,
isLoaded, isLoaded,
}; }), [
followedUserIds,
followedPlaylistIds,
followUser,
unfollowUser,
followPlaylist,
unfollowPlaylist,
isLoaded,
]);
return ( return (
<FollowContext.Provider value={value}> <FollowContext.Provider value={value}>

View File

@@ -1,13 +1,15 @@
import { useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts"; import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
export function PlayerProvider({ children }: { children: React.ReactNode }) { export function PlayerProvider({ children }: { children: React.ReactNode }) {
const [current, setCurrent] = useState<PlayerItem | null>(null); const [current, setCurrent] = useState<PlayerItem | null>(null);
const play = setCurrent;
const stop = useCallback(() => setCurrent(null), []);
const value = useMemo(() => ({ current, play, stop }), [current, play, stop]);
return ( return (
<PlayerContext.Provider <PlayerContext.Provider value={value}>
value={{ current, play: setCurrent, stop: () => setCurrent(null) }}
>
{children} {children}
</PlayerContext.Provider> </PlayerContext.Provider>
); );

View File

@@ -5,6 +5,7 @@ import type {
Notification, Notification,
OnlineUser, OnlineUser,
Playlist, Playlist,
PublicUser,
} from "../model.ts"; } from "../model.ts";
export interface VoteEvent { export interface VoteEvent {
@@ -28,6 +29,10 @@ export interface CommentEvent {
commentId?: string; commentId?: string;
} }
export interface UserEvent {
user: PublicUser;
}
export interface WSContextValue { export interface WSContextValue {
onlineUsers: OnlineUser[]; onlineUsers: OnlineUser[];
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
@@ -39,6 +44,7 @@ export interface WSContextValue {
lastPlaylistEvent: PlaylistEvent | null; lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>; deletedPlaylistIds: Set<string>;
lastCommentEvent: CommentEvent | null; lastCommentEvent: CommentEvent | null;
lastUserEvent: UserEvent | null;
unreadNotificationCount: number; unreadNotificationCount: number;
lastNotification: Notification | null; lastNotification: Notification | null;
castVote: (dumpId: string) => void; castVote: (dumpId: string) => void;
@@ -58,6 +64,7 @@ export const WSContext = createContext<WSContextValue>({
lastPlaylistEvent: null, lastPlaylistEvent: null,
deletedPlaylistIds: new Set(), deletedPlaylistIds: new Set(),
lastCommentEvent: null, lastCommentEvent: null,
lastUserEvent: null,
unreadNotificationCount: 0, unreadNotificationCount: 0,
lastNotification: null, lastNotification: null,
castVote: () => {}, castVote: () => {},

View File

@@ -3,12 +3,14 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { import {
type CommentEvent, type CommentEvent,
type PlaylistEvent, type PlaylistEvent,
type UserEvent,
type VoteEvent, type VoteEvent,
WSContext, WSContext,
type WSContextValue, type WSContextValue,
@@ -22,23 +24,79 @@ import type {
RawDump, RawDump,
RawNotification, RawNotification,
RawPlaylist, RawPlaylist,
RawPublicUser,
} from "../model.ts"; } from "../model.ts";
import { import {
deserializeComment, deserializeComment,
deserializeDump, deserializeDump,
deserializeNotification, deserializeNotification,
deserializePlaylist, deserializePlaylist,
deserializePublicUser,
} from "../model.ts"; } from "../model.ts";
interface WSProviderProps { interface WSProviderProps {
children: ReactNode; children: ReactNode;
token: string | null; token: string | null;
userId: string | null;
} }
const MAX_BACKOFF = 30_000; const MAX_BACKOFF = 30_000;
const ACK_TIMEOUT = 5_000; const ACK_TIMEOUT = 5_000;
export function WSProvider({ children, token }: WSProviderProps) { // ── Type guards for incoming WS messages ──────────────────────────────────────
function isOnlineUser(obj: unknown): obj is OnlineUser {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.userId === "string" &&
typeof o.username === "string" &&
typeof o.hasAvatar === "boolean";
}
function isOnlineUserArray(val: unknown): val is OnlineUser[] {
return Array.isArray(val) && val.every(isOnlineUser);
}
function isStringArray(val: unknown): val is string[] {
return Array.isArray(val) && val.every((x) => typeof x === "string");
}
function isVotesUpdatePayload(
msg: Record<string, unknown>,
): msg is { dumpId: string; voteCount: number; voterId: string; action: "cast" | "remove" } {
return typeof msg.dumpId === "string" &&
typeof msg.voteCount === "number" &&
typeof msg.voterId === "string" &&
(msg.action === "cast" || msg.action === "remove");
}
function isVoteAckPayload(
msg: Record<string, unknown>,
): msg is { dumpId: string; action: "cast" | "remove"; voteCount: number } {
return typeof msg.dumpId === "string" &&
(msg.action === "cast" || msg.action === "remove") &&
typeof msg.voteCount === "number";
}
function isPlaylistDeletedPayload(
msg: Record<string, unknown>,
): msg is { playlistId: string; userId: string } {
return typeof msg.playlistId === "string" && typeof msg.userId === "string";
}
function isPlaylistDumpsUpdatedPayload(
msg: Record<string, unknown>,
): msg is { playlistId: string; dumpIds: string[] } {
return typeof msg.playlistId === "string" && isStringArray(msg.dumpIds);
}
function isCommentDeletedPayload(
msg: Record<string, unknown>,
): msg is { commentId: string; dumpId: string } {
return typeof msg.commentId === "string" && typeof msg.dumpId === "string";
}
export function WSProvider({ children, token, userId }: WSProviderProps) {
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]); const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({}); const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
const [myVotes, setMyVotes] = useState<Set<string>>(new Set()); const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
@@ -55,6 +113,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>( const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
null, null,
); );
const [lastUserEvent, setLastUserEvent] = useState<UserEvent | null>(null);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0); const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const [lastNotification, setLastNotification] = useState<Notification | null>( const [lastNotification, setLastNotification] = useState<Notification | null>(
null, null,
@@ -63,9 +122,11 @@ export function WSProvider({ children, token }: WSProviderProps) {
// Refs to avoid stale closures in event handlers // Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts); const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes); const myVotesRef = useRef(myVotes);
const userIdRef = useRef(userId);
useLayoutEffect(() => { useLayoutEffect(() => {
voteCountsRef.current = voteCounts; voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes; myVotesRef.current = myVotes;
userIdRef.current = userId;
}); });
const socketRef = useRef<WebSocket | null>(null); const socketRef = useRef<WebSocket | null>(null);
@@ -103,41 +164,48 @@ export function WSProvider({ children, token }: WSProviderProps) {
case "welcome": { case "welcome": {
backoff = 500; // reset backoff on successful connect backoff = 500; // reset backoff on successful connect
const users = msg.users as OnlineUser[]; if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) break;
const votes = msg.myVotes as string[]; setOnlineUsers(msg.users);
setOnlineUsers(users); setMyVotes(new Set(msg.myVotes));
setMyVotes(new Set(votes));
setUnreadNotificationCount( setUnreadNotificationCount(
(msg.unreadNotificationCount as number) ?? 0, typeof msg.unreadNotificationCount === "number"
? msg.unreadNotificationCount
: 0,
); );
break; break;
} }
case "presence_update": case "presence_update":
setOnlineUsers(msg.users as OnlineUser[]); if (isOnlineUserArray(msg.users)) setOnlineUsers(msg.users);
break; break;
case "votes_update": { case "votes_update": {
const { dumpId, voteCount, voterId, action } = msg as { if (!isVotesUpdatePayload(msg)) break;
dumpId: string; const { dumpId, voteCount, voterId, action } = msg;
voteCount: number;
voterId: string;
action: "cast" | "remove";
};
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount })); setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
if (voterId && action) { setLastVoteEvent({ dumpId, voterId, action });
setLastVoteEvent({ dumpId, voterId, action }); // Keep myVotes in sync across tabs: if this vote event belongs to
// the current user (from another tab), update myVotes accordingly.
if (voterId === userIdRef.current) {
setMyVotes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(dumpId);
else next.delete(dumpId);
return next;
});
} }
break; break;
} }
case "dump_created": { case "dump_created": {
if (!msg.dump || typeof msg.dump !== "object") break;
const dump = deserializeDump(msg.dump as RawDump); const dump = deserializeDump(msg.dump as RawDump);
setRecentDumps((prev) => [dump, ...prev]); setRecentDumps((prev) => [dump, ...prev]);
break; break;
} }
case "dump_updated": { case "dump_updated": {
if (!msg.dump || typeof msg.dump !== "object") break;
const dump = deserializeDump(msg.dump as RawDump); const dump = deserializeDump(msg.dump as RawDump);
setLastDumpEvent(dump); setLastDumpEvent(dump);
// Un-delete if this dump was previously removed from the feed // Un-delete if this dump was previously removed from the feed
@@ -156,18 +224,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
} }
case "dump_deleted": { case "dump_deleted": {
const dumpId = msg.dumpId as string; if (typeof msg.dumpId !== "string") break;
const dumpId = msg.dumpId;
setDeletedDumpIds((prev) => new Set([...prev, dumpId])); setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId)); setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
break; break;
} }
case "vote_ack": { case "vote_ack": {
const { dumpId, action, voteCount } = msg as { if (!isVoteAckPayload(msg)) break;
dumpId: string; const { dumpId, action, voteCount } = msg;
action: "cast" | "remove";
voteCount: number;
};
// Clear pending revert timeout // Clear pending revert timeout
const timeout = pendingRef.current.get(dumpId); const timeout = pendingRef.current.get(dumpId);
if (timeout !== undefined) { if (timeout !== undefined) {
@@ -188,6 +254,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
case "playlist_created": case "playlist_created":
case "playlist_updated": { case "playlist_updated": {
if (!msg.playlist || typeof msg.playlist !== "object") break;
const playlist = deserializePlaylist(msg.playlist as RawPlaylist); const playlist = deserializePlaylist(msg.playlist as RawPlaylist);
setLastPlaylistEvent({ setLastPlaylistEvent({
type: msg.type === "playlist_created" ? "created" : "updated", type: msg.type === "playlist_created" ? "created" : "updated",
@@ -198,20 +265,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
} }
case "playlist_deleted": { case "playlist_deleted": {
const { playlistId, userId } = msg as { if (!isPlaylistDeletedPayload(msg)) break;
playlistId: string; const { playlistId, userId } = msg;
userId: string;
};
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId])); setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
setLastPlaylistEvent({ type: "deleted", playlistId, userId }); setLastPlaylistEvent({ type: "deleted", playlistId, userId });
break; break;
} }
case "playlist_dumps_updated": { case "playlist_dumps_updated": {
const { playlistId, dumpIds } = msg as { if (!isPlaylistDumpsUpdatedPayload(msg)) break;
playlistId: string; const { playlistId, dumpIds } = msg;
dumpIds: string[];
};
setLastPlaylistEvent({ setLastPlaylistEvent({
type: "dumps_updated", type: "dumps_updated",
playlistId, playlistId,
@@ -220,7 +283,15 @@ export function WSProvider({ children, token }: WSProviderProps) {
break; break;
} }
case "user_updated": {
if (!msg.user || typeof msg.user !== "object") break;
const user = deserializePublicUser(msg.user as RawPublicUser);
setLastUserEvent({ user });
break;
}
case "comment_created": { case "comment_created": {
if (!msg.comment || typeof msg.comment !== "object") break;
const comment = deserializeComment(msg.comment as RawComment); const comment = deserializeComment(msg.comment as RawComment);
setLastCommentEvent({ setLastCommentEvent({
type: "created", type: "created",
@@ -231,15 +302,14 @@ export function WSProvider({ children, token }: WSProviderProps) {
} }
case "comment_deleted": { case "comment_deleted": {
const { commentId, dumpId } = msg as { if (!isCommentDeletedPayload(msg)) break;
commentId: string; const { commentId, dumpId } = msg;
dumpId: string;
};
setLastCommentEvent({ type: "deleted", dumpId, commentId }); setLastCommentEvent({ type: "deleted", dumpId, commentId });
break; break;
} }
case "comment_updated": { case "comment_updated": {
if (!msg.comment || typeof msg.comment !== "object") break;
const comment = deserializeComment(msg.comment as RawComment); const comment = deserializeComment(msg.comment as RawComment);
setLastCommentEvent({ setLastCommentEvent({
type: "updated", type: "updated",
@@ -250,6 +320,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
} }
case "notification_created": { case "notification_created": {
if (!msg.notification || typeof msg.notification !== "object") break;
const notification = deserializeNotification( const notification = deserializeNotification(
msg.notification as RawNotification, msg.notification as RawNotification,
); );
@@ -361,7 +432,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
setUnreadNotificationCount(0); setUnreadNotificationCount(0);
}, []); }, []);
const value: WSContextValue = { const value: WSContextValue = useMemo(() => ({
onlineUsers, onlineUsers,
voteCounts, voteCounts,
myVotes, myVotes,
@@ -372,13 +443,32 @@ export function WSProvider({ children, token }: WSProviderProps) {
lastPlaylistEvent, lastPlaylistEvent,
deletedPlaylistIds, deletedPlaylistIds,
lastCommentEvent, lastCommentEvent,
lastUserEvent,
unreadNotificationCount, unreadNotificationCount,
lastNotification, lastNotification,
castVote, castVote,
removeVote, removeVote,
injectDump, injectDump,
clearUnreadNotifications, clearUnreadNotifications,
}; }), [
onlineUsers,
voteCounts,
myVotes,
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastDumpEvent,
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
lastUserEvent,
unreadNotificationCount,
lastNotification,
castVote,
removeVote,
injectDump,
clearUnreadNotifications,
]);
return ( return (
<WSContext.Provider value={value}> <WSContext.Provider value={value}>

View File

@@ -2,28 +2,39 @@ import { useEffect, useLayoutEffect, useRef } from "react";
import type { Dump } from "../model.ts"; import type { Dump } from "../model.ts";
import { useWS } from "./useWS.ts"; import { useWS } from "./useWS.ts";
interface DumpListSyncOptions {
/** Keep private dumps visible (caller is the owner). */
isOwner?: boolean;
/**
* Only re-insert dumps created by this user when they become visible again.
* Leave undefined to never re-insert (caller handles it, or no re-insertion needed).
*/
ownerId?: string;
/**
* When true, don't re-add a dump that isn't in the list (idx === -1).
* Use this when the caller handles re-insertion itself (e.g. with a position map).
*/
skipReinsert?: boolean;
}
/** /**
* Keeps a dump list in sync with real-time WS events: * Keeps a dump list in sync with real-time WS events:
* - deletedDumpIds: filters out dumps that were deleted or privatised. * - deletedDumpIds growing → filter
* - lastDumpEvent: updates existing dumps in-place; optionally prepends * - lastDumpEvent "updated" → update in-place, or remove if now private
* new ones when `addFilter` returns true for them. * - lastDumpEvent for a not-in-list dump → prepend if ownerId matches and visible
*
* @param setDumps Updater that patches the caller's dump array.
* @param addFilter Optional predicate: return true to prepend a dump that
* isn't already in the list (e.g. became public).
*/ */
export function useDumpListSync( export function useDumpListSync(
setDumps: (fn: (prev: Dump[]) => Dump[]) => void, setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
addFilter?: (dump: Dump) => boolean, options?: DumpListSyncOptions,
): void { ): void {
const { deletedDumpIds, lastDumpEvent } = useWS(); const { deletedDumpIds, lastDumpEvent } = useWS();
// Keep refs up-to-date so closures in effects are never stale. // Keep refs up-to-date so closures in effects are never stale.
const setDumpsRef = useRef(setDumps); const setDumpsRef = useRef(setDumps);
const addFilterRef = useRef(addFilter); const optionsRef = useRef(options);
useLayoutEffect(() => { useLayoutEffect(() => {
setDumpsRef.current = setDumps; setDumpsRef.current = setDumps;
addFilterRef.current = addFilter; optionsRef.current = options;
}); });
useEffect(() => { useEffect(() => {
@@ -35,17 +46,27 @@ export function useDumpListSync(
useEffect(() => { useEffect(() => {
if (!lastDumpEvent) return; if (!lastDumpEvent) return;
const { isOwner, ownerId, skipReinsert } = optionsRef.current ?? {};
const dump = lastDumpEvent;
setDumpsRef.current((prev) => { setDumpsRef.current((prev) => {
const idx = prev.findIndex((d) => d.id === lastDumpEvent.id); const idx = prev.findIndex((d) => d.id === dump.id);
if (idx !== -1) { if (idx !== -1) {
// Remove if it became private and the viewer can't see private dumps.
if (dump.isPrivate && !isOwner) {
return prev.filter((d) => d.id !== dump.id);
}
const next = [...prev]; const next = [...prev];
next[idx] = lastDumpEvent; next[idx] = dump;
return next; return next;
} }
if (addFilterRef.current?.(lastDumpEvent)) {
return [lastDumpEvent, ...prev]; // Dump not in list: only re-insert when ownerId is set and matches.
} if (skipReinsert || !ownerId) return prev;
return prev; if (dump.userId !== ownerId) return prev;
if (dump.isPrivate && !isOwner) return prev;
return [dump, ...prev];
}); });
}, [lastDumpEvent]); }, [lastDumpEvent]);
} }

78
src/hooks/useFading.ts Normal file
View File

@@ -0,0 +1,78 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Manages a 2-second cooldown + 350ms dismissal animation for a set of items.
*
* Call `startFading(id)` when an item should begin fading out.
* Call `cancelFading(id)` to abort the animation (e.g. the action was reversed).
* Use the returned `fading` record to apply CSS classes:
* - `"cooldown"` → item is in the 2-second grace period (darkened)
* - `"dismissing"` → item is collapsing (opacity 0 + grid-row collapse)
*/
export function useFading() {
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
useEffect(() => () => {
cancels.current.forEach((c) => c());
}, []);
const startFading = useCallback((id: string) => {
if (cancels.current.has(id)) return;
let dead = false;
let kill = () => {};
kill = () => {
dead = true;
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
setFading((f) => ({ ...f, [id]: "cooldown" }));
const t1 = setTimeout(() => {
if (dead) return;
setFading((f) => ({ ...f, [id]: "dismissing" }));
const t2 = setTimeout(() => {
if (!dead) kill();
}, 350);
kill = () => {
dead = true;
clearTimeout(t2);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}, 2000);
kill = () => {
dead = true;
clearTimeout(t1);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}, []);
const cancelFading = useCallback((id: string) => {
cancels.current.get(id)?.();
}, []);
/** Cancel all in-progress animations (e.g. on page navigation). */
const cancelAll = useCallback(() => {
cancels.current.forEach((c) => c());
}, []);
return { fading, startFading, cancelFading, cancelAll };
}

View File

@@ -15,6 +15,12 @@ interface PlaylistListSyncOptions {
* (followed membership is managed separately), but still update/remove. * (followed membership is managed separately), but still update/remove.
*/ */
noNewEntries?: boolean; noNewEntries?: boolean;
/**
* When true, don't re-add a playlist that isn't in the list (idx === -1).
* Use this when the caller handles re-insertion itself (e.g. with a position
* map, like the dumps/votes pattern in UserPublicProfile).
*/
skipReinsert?: boolean;
} }
/** /**
@@ -60,6 +66,8 @@ export function usePlaylistListSync(
if (noNewEntries) return prev; if (noNewEntries) return prev;
if (ownerId && ev.playlist.userId !== ownerId) return prev; if (ownerId && ev.playlist.userId !== ownerId) return prev;
if (!ev.playlist.isPublic && !isOwner) return prev; if (!ev.playlist.isPublic && !isOwner) return prev;
// skipReinsert: caller handles re-insertion at the correct position.
if (optionsRef.current?.skipReinsert) return prev;
return [ev.playlist, ...prev]; return [ev.playlist, ...prev];
} }
// Remove if it became private and the viewer can't see private playlists // Remove if it became private and the viewer can't see private playlists
@@ -75,6 +83,14 @@ export function usePlaylistListSync(
return prev.filter((p) => p.id !== ev.playlistId); return prev.filter((p) => p.id !== ev.playlistId);
} }
if (ev.type === "dumps_updated" && ev.dumpIds) {
const idx = prev.findIndex((p) => p.id === ev.playlistId);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = { ...next[idx], dumpCount: ev.dumpIds.length };
return next;
}
return prev; return prev;
}); });
}, [lastPlaylistEvent]); }, [lastPlaylistEvent]);

View File

@@ -0,0 +1,63 @@
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Keeps track of item positions as items leave a list and re-inserts them
* at their original positions when they become visible again.
*
* MUST be called BEFORE the corresponding sync hook (useDumpListSync /
* usePlaylistListSync) so the save-position effect fires before the hook's
* removal effect in the same render cycle.
*
* @param items The current list (pass [] when state is not loaded).
* @param setItems The list setter (same one passed to the sync hook).
* @param lastEvent The latest changed item (Dump | Playlist | null).
* @param isRemoving Return true when the item is about to leave the list
* (e.g. it just became private). Used to save its index.
* @param shouldReinsert Return true when the item should come back at its
* original position (e.g. it just became public again).
*/
export function usePositionAwareSync<T extends { id: string }>(
items: T[],
setItems: (fn: (prev: T[]) => T[]) => void,
lastEvent: T | null,
isRemoving: (item: T) => boolean,
shouldReinsert: (item: T) => boolean,
): void {
const itemsRef = useRef<T[]>(items);
const setItemsRef = useRef(setItems);
const isRemovingRef = useRef(isRemoving);
const shouldReinsertRef = useRef(shouldReinsert);
useLayoutEffect(() => {
itemsRef.current = items;
setItemsRef.current = setItems;
isRemovingRef.current = isRemoving;
shouldReinsertRef.current = shouldReinsert;
});
const removedPositionsRef = useRef<Map<string, number>>(new Map());
// Save position BEFORE the sync hook removes the item.
useEffect(() => {
if (!lastEvent || !isRemovingRef.current(lastEvent)) return;
const idx = itemsRef.current.findIndex((item) => item.id === lastEvent.id);
if (idx !== -1) {
removedPositionsRef.current.set(lastEvent.id, idx);
}
}, [lastEvent]);
// Re-insert at the saved position. Fires before the sync hook's effect for
// the same event; skipReinsert on the sync hook prevents double-insertion.
useEffect(() => {
if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return;
const savedIdx = removedPositionsRef.current.get(lastEvent.id);
if (savedIdx === undefined) return;
removedPositionsRef.current.delete(lastEvent.id);
const item = lastEvent;
setItemsRef.current((prev) => {
if (prev.some((i) => i.id === item.id)) return prev;
const next = [...prev];
next.splice(Math.min(savedIdx, next.length), 0, item);
return next;
});
}, [lastEvent]);
}

View File

@@ -83,6 +83,14 @@ export function deserializeDump(raw: RawDump): Dump {
}; };
} }
export function hydrateDump(raw: unknown): Dump {
return deserializeDump(raw as RawDump);
}
export function hydratePlaylist(raw: unknown): Playlist {
return deserializePlaylist(raw as RawPlaylist);
}
export function deserializeUser(raw: RawUser): User { export function deserializeUser(raw: RawUser): User {
return { return {
...raw, ...raw,

View File

@@ -57,13 +57,16 @@ export function Dump() {
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
const controller = new AbortController();
if (preloaded) { if (preloaded) {
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`) fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`, {
signal: controller.signal,
})
.then((r) => r.json()) .then((r) => r.json())
.then((r) => r.success && setOp(deserializePublicUser(r.data))) .then((r) => r.success && setOp(deserializePublicUser(r.data)))
.catch(() => {}); .catch(() => {});
return; return () => controller.abort();
} }
setDumpState({ status: "loading" }); setDumpState({ status: "loading" });
@@ -73,6 +76,7 @@ export function Dump() {
try { try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store", cache: "no-store",
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
const apiResponse = await res.json(); const apiResponse = await res.json();
@@ -82,14 +86,18 @@ export function Dump() {
const dump: Dump = deserializeDump(apiResponse.data); const dump: Dump = deserializeDump(apiResponse.data);
setDumpState({ status: "loaded", dump }); setDumpState({ status: "loaded", dump });
fetch(`${API_URL}/api/users/by-id/${dump.userId}`) fetch(`${API_URL}/api/users/by-id/${dump.userId}`, {
signal: controller.signal,
})
.then((r) => r.json()) .then((r) => r.json())
.then((r) => r.success && setOp(deserializePublicUser(r.data))) .then((r) => r.success && setOp(deserializePublicUser(r.data)))
.catch(() => {}); .catch(() => {});
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") return;
setDumpState({ status: "error", error: friendlyFetchError(err) }); setDumpState({ status: "error", error: friendlyFetchError(err) });
} }
})(); })();
return () => controller.abort();
}, [selectedDump, preloaded]); }, [selectedDump, preloaded]);
useEffect(() => { useEffect(() => {
@@ -105,7 +113,9 @@ export function Dump() {
// Fetch comments when dump loads // Fetch comments when dump loads
useEffect(() => { useEffect(() => {
if (!selectedDump) return; if (!selectedDump) return;
const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, { fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
@@ -115,6 +125,7 @@ export function Dump() {
} }
}) })
.catch(() => {}); .catch(() => {});
return () => controller.abort();
}, [selectedDump, token]); }, [selectedDump, token]);
// Scroll to and highlight a comment when navigating to #comment-{id} // Scroll to and highlight a comment when navigating to #comment-{id}
@@ -133,8 +144,11 @@ export function Dump() {
}, [comments, location.hash]); }, [comments, location.hash]);
// React to WS comment events // React to WS comment events
// Note: selectedDump may be a slug, but lastCommentEvent.dumpId is always a UUID.
// Compare against the loaded dump's actual ID.
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
useEffect(() => { useEffect(() => {
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return; if (!lastCommentEvent || !loadedDumpId || lastCommentEvent.dumpId !== loadedDumpId) return;
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) { if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
setComments((prev) => { setComments((prev) => {
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) { if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
@@ -161,7 +175,7 @@ export function Dump() {
) )
); );
} }
}, [lastCommentEvent, selectedDump]); }, [lastCommentEvent, loadedDumpId]);
if (dumpState.status === "loading") { if (dumpState.status === "loading") {
return ( return (

View File

@@ -2,6 +2,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
@@ -11,11 +12,12 @@ import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx"; import { DumpCard } from "../components/DumpCard.tsx";
import { AppHeader } from "../components/AppHeader.tsx"; import { AppHeader } from "../components/AppHeader.tsx";
import { API_URL } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { import {
deserializeDump, deserializeDump,
type Dump, type Dump,
hydrateDump,
type PaginatedData, type PaginatedData,
type RawDump, type RawDump,
type User, type User,
@@ -29,12 +31,6 @@ import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
const PAGE_SIZE = 20;
// After JSON roundtrip, createdAt is a string — re-parse it
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
type DumpsState = type DumpsState =
| { status: "loading" } | { status: "loading" }
| { status: "error"; error: string } | { status: "error"; error: string }
@@ -210,11 +206,13 @@ export function Index() {
useEffect(() => { useEffect(() => {
if (mainFetchDone.current || cached) return; if (mainFetchDone.current || cached) return;
mainFetchDone.current = true; mainFetchDone.current = true;
const controller = new AbortController();
(async () => { (async () => {
try { try {
const res = await fetch( const res = await fetch(
`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/dumps/?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}, },
); );
@@ -229,12 +227,17 @@ export function Index() {
loadingMore: false, loadingMore: false,
}); });
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") return;
setDumpsState({ setDumpsState({
status: "error", status: "error",
error: friendlyFetchError(err), error: friendlyFetchError(err),
}); });
} }
})(); })();
return () => {
mainFetchDone.current = false;
controller.abort();
};
}, [cached, token]); }, [cached, token]);
// ── Followed feeds fetch (lazy, on first tab open) ── // ── Followed feeds fetch (lazy, on first tab open) ──
@@ -252,7 +255,7 @@ export function Index() {
loadingMore: false, loadingMore: false,
}); });
} else { } else {
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${PAGE_SIZE}`, { fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
.then((r) => r.json()) .then((r) => r.json())
@@ -286,7 +289,7 @@ export function Index() {
}); });
} else { } else {
fetch( fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}, },
@@ -312,7 +315,7 @@ export function Index() {
} }
}, [ }, [
tab, tab,
user?.id, user,
token, token,
cachedFollowedUsers, cachedFollowedUsers,
cachedFollowedPlaylists, cachedFollowedPlaylists,
@@ -331,7 +334,7 @@ export function Index() {
setDumpsState((s) => setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s s.status === "loaded" ? { ...s, loadingMore: true } : s
); );
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${PAGE_SIZE}`, { fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
@@ -368,7 +371,7 @@ export function Index() {
s.status === "loaded" ? { ...s, loadingMore: true } : s s.status === "loaded" ? { ...s, loadingMore: true } : s
); );
fetch( fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${PAGE_SIZE}`, `${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}, },
@@ -407,7 +410,7 @@ export function Index() {
s.status === "loaded" ? { ...s, loadingMore: true } : s s.status === "loaded" ? { ...s, loadingMore: true } : s
); );
fetch( fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${PAGE_SIZE}`, `${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}, },
@@ -529,7 +532,7 @@ export function Index() {
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : []; const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore; const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const restIds = new Set(dumps.map((d) => d.id)); const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps] const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId); .filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "../components/ErrorCard.tsx"; import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx"; import { Tooltip } from "../components/Tooltip.tsx";
@@ -22,8 +22,6 @@ import { deserializeNotification } from "../model.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { friendlyFetchError } from "../utils/apiError.ts"; import { friendlyFetchError } from "../utils/apiError.ts";
const PAGE_SIZE = 30;
type State = type State =
| { status: "loading" } | { status: "loading" }
| { status: "error"; error: string } | { status: "error"; error: string }
@@ -219,7 +217,7 @@ export function Notifications() {
useEffect(() => { useEffect(() => {
// 1. Fetch with original read state so unread items are highlighted // 1. Fetch with original read state so unread items are highlighted
// 2. Only after displaying, mark all read on the server // 2. Only after displaying, mark all read on the server
authFetch(`${API_URL}/api/notifications?page=1&limit=${PAGE_SIZE}`) authFetch(`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`)
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
if (!body.success) throw new Error("Failed to load"); if (!body.success) throw new Error("Failed to load");
@@ -270,7 +268,7 @@ export function Notifications() {
const nextPage = state.page + 1; const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
authFetch( authFetch(
`${API_URL}/api/notifications?page=${nextPage}&limit=${PAGE_SIZE}`, `${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`,
) )
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {

View File

@@ -92,10 +92,16 @@ export function PlaylistDetail() {
cancels.current.forEach((c) => c()); cancels.current.forEach((c) => c());
}, []); }, []);
const fetchAbortRef = useRef<AbortController | null>(null);
const fetchPlaylist = () => { const fetchPlaylist = () => {
if (!playlistId) return; if (!playlistId) return;
fetchAbortRef.current?.abort();
const controller = new AbortController();
fetchAbortRef.current = controller;
setState({ status: "loading" }); setState({ status: "loading" });
fetch(`${API_URL}/api/playlists/${playlistId}`, { fetch(`${API_URL}/api/playlists/${playlistId}`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => { .then((r) => {
@@ -122,6 +128,7 @@ export function PlaylistDetail() {
cancels.current.clear(); cancels.current.clear();
}) })
.catch((err) => { .catch((err) => {
if (err.name === "AbortError") return;
setState({ setState({
status: "error", status: "error",
error: friendlyFetchError(err), error: friendlyFetchError(err),
@@ -131,6 +138,7 @@ export function PlaylistDetail() {
useEffect(() => { useEffect(() => {
fetchPlaylist(); fetchPlaylist();
return () => fetchAbortRef.current?.abort();
}, [playlistId]); }, [playlistId]);
// Start the cooldown→dismissing→gone sequence for a dump being removed. // Start the cooldown→dismissing→gone sequence for a dump being removed.
@@ -465,8 +473,10 @@ export function PlaylistDetail() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
title: editTitle, ...(editTitle !== state.playlist.title ? { title: editTitle } : {}),
description: editDescription || undefined, ...(editDescription !== (state.playlist.description ?? "")
? { description: editDescription || null }
: {}),
isPublic: editIsPublic, isPublic: editIsPublic,
}), }),
}, },

View File

@@ -7,13 +7,14 @@ import {
} from "react"; } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts"; import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts"; import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts"; import { deserializeDump, deserializePublicUser, hydrateDump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx"; import { Avatar } from "../components/Avatar.tsx";
@@ -22,10 +23,6 @@ import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
type State = type State =
| { status: "loading" } | { status: "loading" }
| { status: "error"; error: string } | { status: "error"; error: string }
@@ -41,7 +38,7 @@ type State =
export function UserDumps() { export function UserDumps() {
const { username } = useParams(); const { username } = useParams();
const { user: me, token } = useAuth(); const { user: me, token } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS(); const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
const { cached, saveState } = useFeedCache<Dump>( const { cached, saveState } = useFeedCache<Dump>(
`feed:user-dumps-full:${username ?? ""}`, `feed:user-dumps-full:${username ?? ""}`,
hydrateDump, hydrateDump,
@@ -56,19 +53,27 @@ export function UserDumps() {
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => { const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }); setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
}, []); }, []);
const addFilter = useCallback((dump: Dump): boolean => { const dumpItems = state.status === "loaded" ? state.dumps : [];
if (!profileUserId) return false; usePositionAwareSync(
if (dump.userId !== profileUserId) return false; dumpItems,
return isOwnProfile || !dump.isPrivate; setDumps,
}, [profileUserId, isOwnProfile]); lastDumpEvent,
useDumpListSync(setDumps, addFilter); (d) => d.isPrivate,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setDumps, {
ownerId: profileUserId ?? undefined,
isOwner: isOwnProfile,
skipReinsert: true,
});
useEffect(() => { useEffect(() => {
if (!username) return; if (!username) return;
setState({ status: "loading" }); setState({ status: "loading" });
const controller = new AbortController();
if (cached) { if (cached) {
fetch(`${API_URL}/api/users/${username}`) fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
if (!body.success) throw new Error("User not found"); if (!body.success) throw new Error("User not found");
@@ -81,23 +86,21 @@ export function UserDumps() {
loadingMore: false, loadingMore: false,
}); });
}) })
.catch((err) => .catch((err) => {
setState({ if (err.name === "AbortError") return;
status: "error", setState({ status: "error", error: friendlyFetchError(err) });
error: friendlyFetchError(err), });
}) return () => controller.abort();
);
return;
} }
const authHeaders: HeadersInit = token const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` } ? { Authorization: `Bearer ${token}` }
: {}; : {};
Promise.all([ Promise.all([
fetch(`${API_URL}/api/users/${username}`), fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch( fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders }, { headers: authHeaders, signal: controller.signal },
), ),
]) ])
.then(([userRes, dumpsRes]) => .then(([userRes, dumpsRes]) =>
@@ -117,12 +120,11 @@ export function UserDumps() {
loadingMore: false, loadingMore: false,
}); });
}) })
.catch((err) => .catch((err) => {
setState({ if (err.name === "AbortError") return;
status: "error", setState({ status: "error", error: friendlyFetchError(err) });
error: friendlyFetchError(err), });
}) return () => controller.abort();
);
}, [username]); }, [username]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -133,7 +135,7 @@ export function UserDumps() {
const nextPage = state.page + 1; const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch( fetch(
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} }, { headers: token ? { Authorization: `Bearer ${token}` } : {} },
) )
.then((r) => r.json()) .then((r) => r.json())

View File

@@ -7,7 +7,7 @@ import {
} from "react"; } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts"; import { friendlyFetchError } from "../utils/apiError.ts";
import type { import type {
PaginatedData, PaginatedData,
@@ -15,9 +15,11 @@ import type {
PublicUser, PublicUser,
RawPlaylist, RawPlaylist,
} from "../model.ts"; } from "../model.ts";
import { deserializePlaylist, deserializePublicUser } from "../model.ts"; import { deserializePlaylist, deserializePublicUser, hydratePlaylist } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts"; import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx"; import { Avatar } from "../components/Avatar.tsx";
@@ -27,10 +29,6 @@ import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
const PAGE_SIZE = 20;
const hydratePlaylist = (raw: Playlist): Playlist =>
deserializePlaylist(raw as unknown as RawPlaylist);
interface PlaylistFeed { interface PlaylistFeed {
items: Playlist[]; items: Playlist[];
hasMore: boolean; hasMore: boolean;
@@ -55,6 +53,7 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
export function UserPlaylists() { export function UserPlaylists() {
const { username } = useParams(); const { username } = useParams();
const { user: me, authFetch, token } = useAuth(); const { user: me, authFetch, token } = useAuth();
const { lastPlaylistEvent } = useWS();
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache< const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
Playlist Playlist
@@ -82,9 +81,21 @@ export function UserPlaylists() {
: { ...s, created: { ...s.created, items: fn(s.created.items) } } : { ...s, created: { ...s.created, items: fn(s.created.items) } }
); );
}, []); }, []);
const createdItems = state.status === "loaded" ? state.created.items : [];
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
? (lastPlaylistEvent.playlist ?? null)
: null;
usePositionAwareSync(
createdItems,
setCreated,
lastPlaylistItem,
(p) => !p.isPublic,
(p) => p.isPublic && p.userId === profileUserId,
);
usePlaylistListSync(setCreated, { usePlaylistListSync(setCreated, {
isOwner: isOwnProfile, isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined, ownerId: profileUserId ?? undefined,
skipReinsert: true,
}); });
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
@@ -99,13 +110,14 @@ export function UserPlaylists() {
useEffect(() => { useEffect(() => {
if (!username) return; if (!username) return;
setState({ status: "loading" }); setState({ status: "loading" });
const controller = new AbortController();
const authHeaders: HeadersInit = token const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` } ? { Authorization: `Bearer ${token}` }
: {}; : {};
if (cachedCreated && cachedFollowed) { if (cachedCreated && cachedFollowed) {
fetch(`${API_URL}/api/users/${username}`) fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
if (!body.success) throw new Error("User not found"); if (!body.success) throw new Error("User not found");
@@ -126,23 +138,22 @@ export function UserPlaylists() {
}, },
}); });
}) })
.catch((err) => .catch((err) => {
setState({ if (err.name === "AbortError") return;
status: "error", setState({ status: "error", error: friendlyFetchError(err) });
error: friendlyFetchError(err), });
}) return () => controller.abort();
);
return;
} }
Promise.all([ Promise.all([
fetch(`${API_URL}/api/users/${username}`), fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch( fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders }, { headers: authHeaders, signal: controller.signal },
), ),
fetch( fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ signal: controller.signal },
), ),
]) ])
.then(([userRes, createdRes, followedRes]) => .then(([userRes, createdRes, followedRes]) =>
@@ -169,12 +180,11 @@ export function UserPlaylists() {
), ),
}); });
}) })
.catch((err) => .catch((err) => {
setState({ if (err.name === "AbortError") return;
status: "error", setState({ status: "error", error: friendlyFetchError(err) });
error: friendlyFetchError(err), });
}) return () => controller.abort();
);
}, [username]); }, [username]);
const loadMoreCreated = useCallback(() => { const loadMoreCreated = useCallback(() => {
@@ -189,7 +199,7 @@ export function UserPlaylists() {
: s : s
); );
fetch( fetch(
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} }, { headers: token ? { Authorization: `Bearer ${token}` } : {} },
) )
.then((r) => r.json()) .then((r) => r.json())
@@ -230,7 +240,7 @@ export function UserPlaylists() {
: s : s
); );
fetch( fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
) )
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {

View File

@@ -7,13 +7,15 @@ import React, {
} from "react"; } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts"; import type { Dump, PaginatedData, PublicUser } from "../model.ts";
import { import {
deserializeAuthResponse, deserializeAuthResponse,
deserializeDump, deserializeDump,
deserializePublicUser, deserializePublicUser,
deserializeUser, deserializeUser,
hydrateDump,
hydratePlaylist,
type RawDump, type RawDump,
type RawUser, type RawUser,
} from "../model.ts"; } from "../model.ts";
@@ -26,7 +28,9 @@ import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts"; import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import type { Playlist, RawPlaylist } from "../model.ts"; import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts"; import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts";
@@ -37,8 +41,6 @@ import { friendlyFetchError } from "../utils/apiError.ts";
import { TextEditor } from "../components/TextEditor.tsx"; import { TextEditor } from "../components/TextEditor.tsx";
import { Markdown } from "../components/Markdown.tsx"; import { Markdown } from "../components/Markdown.tsx";
const PAGE_SIZE = 20;
function InviteButton() { function InviteButton() {
const { authFetch } = useAuth(); const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null); const [inviteUrl, setInviteUrl] = useState<string | null>(null);
@@ -89,11 +91,6 @@ function InviteButton() {
); );
} }
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
const hydratePlaylist = (raw: Playlist): Playlist =>
deserializePlaylist(raw as unknown as RawPlaylist);
interface PaginatedList<T> { interface PaginatedList<T> {
items: T[]; items: T[];
hasMore: boolean; hasMore: boolean;
@@ -125,6 +122,8 @@ export function UserPublicProfile() {
myVotes, myVotes,
lastVoteEvent, lastVoteEvent,
lastDumpEvent, lastDumpEvent,
lastPlaylistEvent,
lastUserEvent,
castVote, castVote,
removeVote, removeVote,
} = useWS(); } = useWS();
@@ -149,89 +148,47 @@ export function UserPublicProfile() {
const profileUserId = state.status === "loaded" ? state.user.id : null; const profileUserId = state.status === "loaded" ? state.user.id : null;
const isOwnProfile = me?.id === profileUserId; const isOwnProfile = me?.id === profileUserId;
const removedDumpPositionsRef = useRef<Map<string, number>>(new Map());
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => { const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => { setState((s) =>
if (s.status !== "loaded") return s; s.status !== "loaded"
const prev = s.dumps.items; ? s
const next = fn(prev); : { ...s, dumps: { ...s.dumps, items: fn(s.dumps.items) } }
if (next.length < prev.length) { );
const nextIds = new Set(next.map((d) => d.id));
prev.forEach((d, idx) => {
if (!nextIds.has(d.id)) {
removedDumpPositionsRef.current.set(d.id, idx);
}
});
}
return { ...s, dumps: { ...s.dumps, items: next } };
});
}, []); }, []);
// No addFilter — insertion at correct position is handled by the effect below. const dumpItems = state.status === "loaded" ? state.dumps.items : [];
useDumpListSync(setDumps); usePositionAwareSync(
dumpItems,
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>( setDumps,
new Set(), lastDumpEvent,
(d) => d.isPrivate,
(d) => !d.isPrivate && d.userId === profileUserId,
); );
useDumpListSync(setDumps, {
ownerId: profileUserId ?? undefined,
isOwner: isOwnProfile,
skipReinsert: true,
});
// Tracks the list index of each dump at the moment it was removed from the
// votes list, so we can re-insert it at the correct position when it becomes
// public again (instead of always prepending at position 0).
const removedVotePositionsRef = useRef<Map<string, number>>(new Map());
// Dump IDs removed due to vote withdrawal — must not be re-inserted on // Dump IDs removed due to vote withdrawal — must not be re-inserted on
// a future dump_updated event (that would only be for private→public transitions). // a future dump_updated event (that would only be for private→public transitions).
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set()); const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => { const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => { setState((s) =>
if (s.status !== "loaded") return s; s.status !== "loaded"
const prev = s.votes.items; ? s
const next = fn(prev); : { ...s, votes: { ...s.votes, items: fn(s.votes.items) } }
if (next.length < prev.length) { );
const nextIds = new Set(next.map((d) => d.id));
prev.forEach((d, idx) => {
if (!nextIds.has(d.id)) {
removedVotePositionsRef.current.set(d.id, idx);
}
});
}
return { ...s, votes: { ...s.votes, items: next } };
});
}, []); }, []);
useDumpListSync(setVotes); const voteItems = state.status === "loaded" ? state.votes.items : [];
usePositionAwareSync(
// Re-insert a vote-list dump at its original position after private→public. voteItems,
// Skip dumps whose vote was explicitly withdrawn (those were removed intentionally). setVotes,
useEffect(() => { lastDumpEvent,
if (!lastDumpEvent || lastDumpEvent.isPrivate) return; (d) => d.isPrivate,
const dump = lastDumpEvent; (d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id),
if (withdrawnVoteIdsRef.current.has(dump.id)) return; );
const savedIdx = removedVotePositionsRef.current.get(dump.id); useDumpListSync(setVotes, { skipReinsert: true });
if (savedIdx === undefined) return;
removedVotePositionsRef.current.delete(dump.id);
setVotes((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev;
const next = [...prev];
next.splice(Math.min(savedIdx, next.length), 0, dump);
return next;
});
}, [lastDumpEvent, setVotes]);
// Re-insert a dumps-column dump at its original position after private→public.
useEffect(() => {
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
const dump = lastDumpEvent;
if (dump.userId !== profileUserId) return;
const savedIdx = removedDumpPositionsRef.current.get(dump.id);
if (savedIdx === undefined) return;
removedDumpPositionsRef.current.delete(dump.id);
setDumps((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev;
const next = [...prev];
next.splice(Math.min(savedIdx, next.length), 0, dump);
return next;
});
}, [lastDumpEvent, profileUserId, setDumps]);
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) => setState((s) =>
@@ -240,11 +197,33 @@ export function UserPublicProfile() {
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } } : { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
); );
}, []); }, []);
const playlistItems = state.status === "loaded" ? state.playlists.items : [];
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
? (lastPlaylistEvent.playlist ?? null)
: null;
usePositionAwareSync(
playlistItems,
setPlaylists,
lastPlaylistItem,
(p) => !p.isPublic,
(p) => p.isPublic && p.userId === profileUserId,
);
usePlaylistListSync(setPlaylists, { usePlaylistListSync(setPlaylists, {
isOwner: isOwnProfile, isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined, ownerId: profileUserId ?? undefined,
skipReinsert: true,
}); });
// Update profile user when they edit their own profile
useEffect(() => {
if (!lastUserEvent) return;
const { user } = lastUserEvent;
setState((s) => {
if (s.status !== "loaded" || s.user.id !== user.id) return s;
return { ...s, user };
});
}, [lastUserEvent]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null); const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -258,18 +237,21 @@ export function UserPublicProfile() {
useEffect(() => { useEffect(() => {
if (!username) return; if (!username) return;
setState({ status: "loading" }); setState({ status: "loading" });
prevMyVotesRef.current = null;
const controller = new AbortController();
const allCached = cachedDumps && cachedVotes && cachedPlaylists; const allCached = cachedDumps && cachedVotes && cachedPlaylists;
if (allCached) { if (allCached) {
// Only fetch the user object (lightweight, always fresh) // Only fetch the user object (lightweight, always fresh)
fetch(`${API_URL}/api/users/${username}`) fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
if (!body.success) throw new Error("User not found"); if (!body.success) throw new Error("User not found");
const profileUser = deserializePublicUser(body.data);
setState({ setState({
status: "loaded", status: "loaded",
user: deserializePublicUser(body.data), user: profileUser,
dumps: { dumps: {
items: cachedDumps.items, items: cachedDumps.items,
hasMore: cachedDumps.hasMore, hasMore: cachedDumps.hasMore,
@@ -289,15 +271,12 @@ export function UserPublicProfile() {
loadingMore: false, loadingMore: false,
}, },
}); });
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
}) })
.catch((err) => .catch((err) => {
setState({ if (err.name === "AbortError") return;
status: "error", setState({ status: "error", error: friendlyFetchError(err) });
error: friendlyFetchError(err), });
}) return () => controller.abort();
);
return;
} }
(async () => { (async () => {
@@ -306,18 +285,20 @@ export function UserPublicProfile() {
? { Authorization: `Bearer ${token}` } ? { Authorization: `Bearer ${token}` }
: {}; : {};
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([ const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
fetch(`${API_URL}/api/users/${username}`), fetch(`${API_URL}/api/users/${username}`, {
signal: controller.signal,
}),
fetch( fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders }, { headers: authHeaders, signal: controller.signal },
), ),
fetch( fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders }, { headers: authHeaders, signal: controller.signal },
), ),
fetch( fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders }, { headers: authHeaders, signal: controller.signal },
), ),
]); ]);
@@ -347,10 +328,11 @@ export function UserPublicProfile() {
? dumpsBody.data ? dumpsBody.data
: { items: [], total: 0, hasMore: false }; : { items: [], total: 0, hasMore: false };
const profileUser = deserializePublicUser(userBody.data);
const voteItems = votesData.items.map(deserializeDump); const voteItems = votesData.items.map(deserializeDump);
setState({ setState({
status: "loaded", status: "loaded",
user: deserializePublicUser(userBody.data), user: profileUser,
dumps: initialList( dumps: initialList(
dumpsData.items.map(deserializeDump), dumpsData.items.map(deserializeDump),
dumpsData.hasMore, dumpsData.hasMore,
@@ -361,20 +343,20 @@ export function UserPublicProfile() {
playlistsData.hasMore, playlistsData.hasMore,
), ),
}); });
setProfileVotedIds(new Set(voteItems.map((d) => d.id)));
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") return;
setState({ setState({
status: "error", status: "error",
error: friendlyFetchError(err), error: friendlyFetchError(err),
}); });
} }
})(); })();
return () => controller.abort();
}, [username]); }, [username]);
// Own profile: keep profileVotedIds in sync with myVotes // Own profile: prepend dumps newly voted by the user to the preview list
useEffect(() => { useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return; if (!profileUserId || me?.id !== profileUserId) return;
setProfileVotedIds(new Set(myVotes));
if (prevMyVotesRef.current === null) { if (prevMyVotesRef.current === null) {
prevMyVotesRef.current = new Set(myVotes); prevMyVotesRef.current = new Set(myVotes);
return; return;
@@ -400,35 +382,28 @@ export function UserPublicProfile() {
if (!lastVoteEvent || !profileUserId) return; if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent; const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return; if (voterId !== profileUserId) return;
const isOwnProfile = me?.id === profileUserId;
if (action === "remove") { if (action === "remove") {
if (!isOwnProfile) { // Keep dump in state.votes.items as a ghost — UpvotedDumpList drives
setProfileVotedIds((prev) => { // its own votedIds + fading state and will animate the removal.
const n = new Set(prev);
n.delete(dumpId);
return n;
});
}
withdrawnVoteIdsRef.current.add(dumpId); withdrawnVoteIdsRef.current.add(dumpId);
setVotes((prev) => prev.filter((d) => d.id !== dumpId));
} else { } else {
withdrawnVoteIdsRef.current.delete(dumpId); withdrawnVoteIdsRef.current.delete(dumpId);
if (!isOwnProfile) {
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
}
fetch(`${API_URL}/api/dumps/${dumpId}`) fetch(`${API_URL}/api/dumps/${dumpId}`)
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
if (!body.success) return; if (!body.success) return;
const dump = deserializeDump(body.data); const dump = deserializeDump(body.data);
setState((s) => { setState((s) => {
if ( if (s.status !== "loaded") return s;
s.status !== "loaded" || const idx = s.votes.items.findIndex((d) => d.id === dumpId);
s.votes.items.some((d) => d.id === dumpId) if (idx !== -1) {
) { // Ghost re-voted: update in-place.
return s; const items = [...s.votes.items];
items[idx] = dump;
return { ...s, votes: { ...s.votes, items } };
} }
// First-time vote: prepend.
return { return {
...s, ...s,
votes: { ...s.votes, items: [dump, ...s.votes.items] }, votes: { ...s.votes, items: [dump, ...s.votes.items] },
@@ -437,7 +412,7 @@ export function UserPublicProfile() {
}) })
.catch(() => {}); .catch(() => {});
} }
}, [lastVoteEvent, me, profileUserId]); }, [lastVoteEvent, profileUserId]);
// Save scroll position + loaded state to sessionStorage on scroll // Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => { useEffect(() => {
@@ -465,6 +440,19 @@ export function UserPublicProfile() {
}; };
}, [state, saveDumps, saveVotes, savePlaylists]); }, [state, saveDumps, saveVotes, savePlaylists]);
// Keep the playlists cache current whenever the list changes (e.g. via WS),
// so a page refresh restores the up-to-date list rather than a stale snapshot.
const playlistFeed = state.status === "loaded" ? state.playlists : null;
useEffect(() => {
if (!playlistFeed) return;
savePlaylists(
playlistFeed.items,
playlistFeed.page,
playlistFeed.hasMore,
globalThis.scrollY,
);
}, [playlistFeed, savePlaylists]);
// Restore scroll position after cache restoration // Restore scroll position after cache restoration
const scrollRestored = useRef(false); const scrollRestored = useRef(false);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -734,9 +722,10 @@ export function UserPublicProfile() {
/> />
<UpvotedDumpList <UpvotedDumpList
title={`Upvoted (${profileVotedIds.size}${votes.hasMore ? "+" : ""})`} title={`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
dumps={votes.items} dumps={votes.items}
votedIds={profileVotedIds} profileUserId={profileUserId}
isOwnProfile={isOwnProfile}
voteCounts={voteCounts} voteCounts={voteCounts}
myVotes={myVotes} myVotes={myVotes}
canVote={!!me} canVote={!!me}
@@ -865,7 +854,8 @@ function UpvotedDumpList(
{ {
title, title,
dumps, dumps,
votedIds, profileUserId,
isOwnProfile,
voteCounts, voteCounts,
myVotes, myVotes,
canVote, canVote,
@@ -875,7 +865,8 @@ function UpvotedDumpList(
}: { }: {
title: string; title: string;
dumps: Dump[]; dumps: Dump[];
votedIds: Set<string>; profileUserId: string | null;
isOwnProfile: boolean;
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
canVote: boolean; canVote: boolean;
@@ -884,84 +875,46 @@ function UpvotedDumpList(
viewAllHref: string; viewAllHref: string;
}, },
) { ) {
const [fading, setFading] = useState< const { myVotes: wsMyVotes, lastVoteEvent } = useWS();
Record<string, "cooldown" | "dismissing"> const { fading, startFading, cancelFading } = useFading();
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
const prevVotedIds = useRef<Set<string> | null>(null);
useEffect(() => () => { // votedIds is managed locally so setVotedIds + startFading/cancelFading can
cancels.current.forEach((c) => c()); // be called in the same effect body — guaranteeing a single render where the
}, []); // dump is always in visibleDumps (with or without fading class). This prevents
// the DOM node from being unmounted/remounted, which would break CSS transitions.
const [votedIds, setVotedIds] = useState(() => new Set(dumps.map((d) => d.id)));
const prevMyVotesRef = useRef<Set<string> | null>(null);
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
useEffect(() => { useEffect(() => {
if (prevVotedIds.current === null) { if (!profileUserId || !isOwnProfile) return;
prevVotedIds.current = new Set(votedIds); if (prevMyVotesRef.current === null) {
setVotedIds(new Set(wsMyVotes));
prevMyVotesRef.current = new Set(wsMyVotes);
return; return;
} }
const prev = prevMyVotesRef.current;
setVotedIds(new Set(wsMyVotes));
for (const id of prev) { if (!wsMyVotes.has(id)) startFading(id); }
for (const id of wsMyVotes) { if (!prev.has(id)) cancelFading(id); }
prevMyVotesRef.current = new Set(wsMyVotes);
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
const prev = prevVotedIds.current; // Non-own profile: sync votedIds with WS vote events for the profile user.
useEffect(() => {
for (const id of prev) { if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
if (!votedIds.has(id) && !cancels.current.has(id)) { const { dumpId, voterId, action } = lastVoteEvent;
let dead = false; if (voterId !== profileUserId) return;
let kill = () => {}; if (action === "remove") {
kill = () => { setVotedIds((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
dead = true; startFading(dumpId);
setFading((f) => { } else {
const n = { ...f }; setVotedIds((prev) => new Set([...prev, dumpId]));
delete n[id]; cancelFading(dumpId);
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
setFading((f) => ({ ...f, [id]: "cooldown" }));
const t1 = setTimeout(() => {
if (dead) return;
setFading((f) => ({ ...f, [id]: "dismissing" }));
const t2 = setTimeout(() => {
if (!dead) kill();
}, 350);
kill = () => {
dead = true;
clearTimeout(t2);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
}, 2000);
kill = () => {
dead = true;
clearTimeout(t1);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}
} }
}, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]);
for (const id of votedIds) { const visibleDumps = dumps.filter((d) => votedIds.has(d.id) || d.id in fading);
if (!prev.has(id) && cancels.current.has(id)) {
cancels.current.get(id)!();
}
}
prevVotedIds.current = new Set(votedIds);
}, [votedIds]);
const visibleDumps = dumps.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
return ( return (
<section className="profile-section"> <section className="profile-section">

View File

@@ -7,13 +7,14 @@ import {
} from "react"; } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts"; import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts"; import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts"; import { deserializeDump, deserializePublicUser, hydrateDump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx"; import { Avatar } from "../components/Avatar.tsx";
@@ -21,10 +22,6 @@ import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
type State = type State =
| { status: "loading" } | { status: "loading" }
| { status: "error"; error: string } | { status: "error"; error: string }
@@ -54,26 +51,19 @@ export function UserUpvoted() {
useDumpListSync(setVotesDumps); useDumpListSync(setVotesDumps);
const [votedIds, setVotedIds] = useState<Set<string>>(new Set()); const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
const [fading, setFading] = useState< const { fading, startFading, cancelFading, cancelAll } = useFading();
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
const prevVotedIds = useRef<Set<string> | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null); const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => () => {
cancels.current.forEach((c) => c());
}, []);
useEffect(() => { useEffect(() => {
if (!username) return; if (!username) return;
setState({ status: "loading" }); setState({ status: "loading" });
cancelAll();
setVotedIds(new Set()); setVotedIds(new Set());
prevVotedIds.current = null;
prevMyVotesRef.current = null; prevMyVotesRef.current = null;
const controller = new AbortController();
if (cached) { if (cached) {
fetch(`${API_URL}/api/users/${username}`) fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
if (!body.success) throw new Error("User not found"); if (!body.success) throw new Error("User not found");
@@ -88,23 +78,21 @@ export function UserUpvoted() {
}); });
setVotedIds(voteIds); setVotedIds(voteIds);
}) })
.catch((err) => .catch((err) => {
setState({ if (err.name === "AbortError") return;
status: "error", setState({ status: "error", error: friendlyFetchError(err) });
error: friendlyFetchError(err), });
}) return () => controller.abort();
);
return;
} }
const authHeaders: HeadersInit = token const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` } ? { Authorization: `Bearer ${token}` }
: {}; : {};
Promise.all([ Promise.all([
fetch(`${API_URL}/api/users/${username}`), fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch( fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders }, { headers: authHeaders, signal: controller.signal },
), ),
]) ])
.then(([userRes, votesRes]) => .then(([userRes, votesRes]) =>
@@ -126,37 +114,32 @@ export function UserUpvoted() {
}); });
setVotedIds(new Set(voteItems.map((d) => d.id))); setVotedIds(new Set(voteItems.map((d) => d.id)));
}) })
.catch((err) => .catch((err) => {
setState({ if (err.name === "AbortError") return;
status: "error", setState({ status: "error", error: friendlyFetchError(err) });
error: friendlyFetchError(err), });
}) return () => controller.abort();
);
}, [username]); }, [username]);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null; const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
// Own profile: keep votedIds in sync with myVotes // Own profile: keep votedIds in sync with myVotes.
// Fading is triggered directly here to avoid a gap render between
// setVotedIds and the old prevVotedIds tracking effect.
useEffect(() => { useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return; if (!profileUserId || me?.id !== profileUserId) return;
setVotedIds(new Set(myVotes));
if (prevMyVotesRef.current === null) { if (prevMyVotesRef.current === null) {
// First sync after load: initialize without animating the diff.
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes); prevMyVotesRef.current = new Set(myVotes);
return; return;
} }
const prev = prevMyVotesRef.current; const prev = prevMyVotesRef.current;
setState((s) => { setVotedIds(new Set(myVotes));
if (s.status !== "loaded") return s; for (const id of prev) { if (!myVotes.has(id)) startFading(id); }
const voteIdSet = new Set(s.votes.map((d) => d.id)); for (const id of myVotes) { if (!prev.has(id)) cancelFading(id); }
const toAdd = [...myVotes].filter((id) =>
!prev.has(id) && !voteIdSet.has(id)
);
if (toAdd.length === 0) return s;
// Newly voted items will arrive via lastVoteEvent fetch below
return s;
});
prevMyVotesRef.current = new Set(myVotes); prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId]); }, [myVotes, me, profileUserId, startFading, cancelFading]);
// WS vote events // WS vote events
useEffect(() => { useEffect(() => {
@@ -170,8 +153,11 @@ export function UserUpvoted() {
n.delete(dumpId); n.delete(dumpId);
return n; return n;
}); });
// Start fading in same batch so visibleDumps never has a gap render.
startFading(dumpId);
} else { } else {
setVotedIds((prev) => new Set([...prev, dumpId])); setVotedIds((prev) => new Set([...prev, dumpId]));
cancelFading(dumpId);
fetch(`${API_URL}/api/dumps/${dumpId}`) fetch(`${API_URL}/api/dumps/${dumpId}`)
.then((r) => r.json()) .then((r) => r.json())
.then((body) => { .then((body) => {
@@ -186,73 +172,8 @@ export function UserUpvoted() {
}) })
.catch(() => {}); .catch(() => {});
} }
}, [lastVoteEvent, profileUserId]); }, [lastVoteEvent, profileUserId, startFading, cancelFading]);
// Fade animation when items leave votedIds
useEffect(() => {
if (prevVotedIds.current === null) {
prevVotedIds.current = new Set(votedIds);
return;
}
const prev = prevVotedIds.current;
for (const id of prev) {
if (!votedIds.has(id) && !cancels.current.has(id)) {
let dead = false;
let kill = () => {};
kill = () => {
dead = true;
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
setFading((f) => ({ ...f, [id]: "cooldown" }));
const t1 = setTimeout(() => {
if (dead) return;
setFading((f) => ({ ...f, [id]: "dismissing" }));
const t2 = setTimeout(() => {
if (!dead) kill();
}, 350);
kill = () => {
dead = true;
clearTimeout(t2);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
}, 2000);
kill = () => {
dead = true;
clearTimeout(t1);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}
}
for (const id of votedIds) {
if (!prev.has(id) && cancels.current.has(id)) {
cancels.current.get(id)!();
}
}
prevVotedIds.current = new Set(votedIds);
}, [votedIds]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if ( if (
@@ -262,7 +183,7 @@ export function UserUpvoted() {
const nextPage = state.page + 1; const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch( fetch(
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, `${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} }, { headers: token ? { Authorization: `Bearer ${token}` } : {} },
) )
.then((r) => r.json()) .then((r) => r.json())
@@ -342,9 +263,7 @@ export function UserUpvoted() {
} }
const { profileUser, votes, hasMore, loadingMore } = state; const { profileUser, votes, hasMore, loadingMore } = state;
const visibleDumps = votes.filter((d) => const visibleDumps = votes.filter((d) => votedIds.has(d.id) || d.id in fading);
votedIds.has(d.id) || d.id in fading
);
return ( return (
<PageShell> <PageShell>