From fbbbb43258da8e852125baec80a5744467d4b8a5 Mon Sep 17 00:00:00 2001 From: khannurien Date: Mon, 23 Mar 2026 07:47:49 +0000 Subject: [PATCH] v3: code quality pass, various bug fixes --- .env.example | 6 +- api/.env.example | 3 - api/lib/auth.ts | 10 + api/lib/jwt.ts | 9 +- api/lib/pagination.ts | 22 ++ api/model/interfaces.ts | 145 +++++++++--- api/routes/avatars.ts | 32 +-- api/routes/comments.ts | 9 +- api/routes/dumps.ts | 37 +-- api/routes/playlists.ts | 41 ++-- api/routes/preview.ts | 6 +- api/routes/users.ts | 87 ++----- api/routes/ws.ts | 4 + api/services/dump-service.ts | 36 ++- api/services/notification-service.ts | 46 +++- api/services/playlist-service.ts | 3 +- api/services/rich-content-service.ts | 10 +- api/services/ws-service.ts | 22 +- api/utils/upload.ts | 21 ++ deno.json | 8 +- src/App.css | 12 +- src/App.tsx | 4 +- src/components/AppHeader.tsx | 7 +- src/components/CommentThread.tsx | 4 +- src/components/Markdown.tsx | 32 ++- src/config/api.ts | 17 ++ src/contexts/AuthProvider.tsx | 8 +- src/contexts/FollowProvider.tsx | 25 +- src/contexts/PlayerProvider.tsx | 10 +- src/contexts/WSContext.ts | 7 + src/contexts/WSProvider.tsx | 160 ++++++++++--- src/hooks/useDumpListSync.ts | 53 +++-- src/hooks/useFading.ts | 78 ++++++ src/hooks/usePlaylistListSync.ts | 16 ++ src/hooks/usePositionAwareSync.ts | 63 +++++ src/model.ts | 8 + src/pages/Dump.tsx | 24 +- src/pages/Index.tsx | 33 +-- src/pages/Notifications.tsx | 8 +- src/pages/PlaylistDetail.tsx | 14 +- src/pages/UserDumps.tsx | 64 ++--- src/pages/UserPlaylists.tsx | 62 +++-- src/pages/UserPublicProfile.tsx | 341 ++++++++++++--------------- src/pages/UserUpvoted.tsx | 151 +++--------- 44 files changed, 1060 insertions(+), 698 deletions(-) delete mode 100644 api/.env.example create mode 100644 api/lib/auth.ts create mode 100644 api/lib/pagination.ts create mode 100644 src/hooks/useFading.ts create mode 100644 src/hooks/usePositionAwareSync.ts diff --git a/.env.example b/.env.example index 88341d2..128a152 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ +GERBEUR_PROTOCOL=http +GERBEUR_HOSTNAME=localhost +GERBEUR_PORT=8000 +JWT_SECRET= + VITE_API_PROTOCOL=http -VITE_WS_PROTOCOL=ws VITE_SERVER_HOST=localhost VITE_SERVER_PORT=8000 \ No newline at end of file diff --git a/api/.env.example b/api/.env.example deleted file mode 100644 index 3d6597a..0000000 --- a/api/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -PROTOCOL=http -HOSTNAME=localhost -PORT=8000 \ No newline at end of file diff --git a/api/lib/auth.ts b/api/lib/auth.ts new file mode 100644 index 0000000..85db43a --- /dev/null +++ b/api/lib/auth.ts @@ -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 { + const authHeader = ctx.request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) return null; + const payload = await verifyJWT(authHeader.substring(7)); + return payload?.userId ?? null; +} diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 6e025f5..121e4d6 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -8,8 +8,13 @@ import { isInvitePayload, } from "../model/interfaces.ts"; -const JWT_SECRET = "FIXME-gerbeur-dev-env"; -const JWT_KEY = new TextEncoder().encode(JWT_SECRET); +const jwtSecret = Deno.env.get("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 ───────────────────────────────────────────────────────────── diff --git a/api/lib/pagination.ts b/api/lib/pagination.ts new file mode 100644 index 0000000..b1ba0da --- /dev/null +++ b/api/lib/pagination.ts @@ -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 }; +} diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 97b3432..a2aff0a 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -2,6 +2,20 @@ * 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 { type: string; url: string; @@ -75,19 +89,42 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { export function isRegisterUserRequest( obj: unknown, ): obj is RegisterUserRequest { - return !!obj && typeof obj === "object" && - "username" in obj && typeof obj.username === "string" && - "password" in obj && typeof obj.password === "string" && - "inviteToken" in obj && typeof obj.inviteToken === "string"; + if ( + !obj || typeof obj !== "object" || + !("username" in obj) || typeof obj.username !== "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 { - return !!obj && typeof obj === "object" && - (!("username" in obj) || typeof obj.username === "string") && - (!("password" in obj) || typeof obj.password === "string") && - (!("isAdmin" in obj) || typeof obj.isAdmin === "boolean") && - (!("description" in obj) || typeof obj.description === "string" || - obj.description === null); + if (!obj || typeof obj !== "object") return false; + const o = obj as Record; + if ("username" in o) { + if (typeof o.username !== "string") return false; + if (!/^[a-zA-Z0-9_]{1,32}$/.test(o.username as string)) return false; + } + 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 { @@ -200,7 +237,9 @@ export function isCreateCommentRequest( ): obj is CreateCommentRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; - 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" || o.parentId === null); } @@ -214,7 +253,9 @@ export function isUpdateCommentRequest( ): obj is UpdateCommentRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; - 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( obj: unknown, ): obj is CreatePlaylistRequest { - return !!obj && typeof obj === "object" && - "title" in obj && typeof obj.title === "string" && - (!("description" in obj) || typeof obj.description === "string" || - obj.description === null) && - "isPublic" in obj && typeof obj.isPublic === "boolean"; + if ( + !obj || typeof obj !== "object" || + !("title" in obj) || typeof obj.title !== "string" || + !("isPublic" in obj) || typeof obj.isPublic !== "boolean" + ) return false; + const o = obj as Record; + 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( obj: unknown, ): obj is UpdatePlaylistRequest { - return !!obj && typeof obj === "object" && - (!("title" in obj) || typeof obj.title === "string") && - (!("description" in obj) || typeof obj.description === "string" || - obj.description === null) && - (!("isPublic" in obj) || typeof obj.isPublic === "boolean"); + if (!obj || typeof obj !== "object") return false; + const o = obj as Record; + if ("title" in o) { + if (typeof o.title !== "string") return false; + 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( @@ -301,12 +364,20 @@ export interface CreateUrlDumpRequest { export function isCreateUrlDumpRequest( obj: unknown, ): obj is CreateUrlDumpRequest { - return !!obj && - typeof obj === "object" && - "url" in obj && typeof obj.url === "string" && - (!("comment" in obj) || - typeof obj.comment === "string" || obj.comment === null) && - (!("isPrivate" in obj) || typeof obj.isPrivate === "boolean"); + if ( + !obj || typeof obj !== "object" || + !("url" in obj) || typeof obj.url !== "string" + ) return false; + const o = obj as Record; + 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 { @@ -316,12 +387,18 @@ export interface UpdateDumpRequest { } export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { - return !!obj && - typeof obj === "object" && - (!("url" in obj) || typeof obj.url === "string" || obj.url === null) && - (!("comment" in obj) || - typeof obj.comment === "string" || obj.comment === null) && - (!("isPrivate" in obj) || typeof obj.isPrivate === "boolean"); + if (!obj || typeof obj !== "object") return false; + const o = obj as Record; + if ("url" in o && typeof o.url !== "string" && o.url !== null) return false; + 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; } /** diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index 1635c87..8180336 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -5,9 +5,8 @@ import { updateClientAvatar } from "../services/ws-service.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { AVATARS_DIR, - detectImageMime, - MAX_IMAGE_SIZE, serveUploadedFile, + validateImageUpload, } from "../utils/upload.ts"; 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"); } - 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 mime = validateImageUpload(data); - const mime = detectImageMime(data); - if (!mime) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "File content is not a recognised image (JPEG, PNG, GIF, WebP)", - ); - } - + const filePath = `${AVATARS_DIR}/${authPayload.userId}`; await Deno.mkdir(AVATARS_DIR, { recursive: true }); - await Deno.writeFile(`${AVATARS_DIR}/${authPayload.userId}`, data); - updateUserAvatar(authPayload.userId, mime); + await Deno.writeFile(filePath, data); + 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); const user = getUserById(authPayload.userId); diff --git a/api/routes/comments.ts b/api/routes/comments.ts index 04c27bf..65ccbba 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -8,7 +8,7 @@ import { isUpdateCommentRequest, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; -import { verifyJWT } from "../lib/jwt.ts"; +import { parseOptionalAuth } from "../lib/auth.ts"; import { createComment, deleteComment, @@ -26,12 +26,7 @@ const router = new Router({ prefix: "/api" }); // GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments) router.get("/dumps/:dumpId/comments", async (ctx) => { - let requestingUserId: string | 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 requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); const comments = getComments(dump.id); const responseBody: APIResponse = { diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 540e80a..08178a2 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -11,7 +11,8 @@ import { } from "../model/interfaces.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 { createFileDump, createUrlDump, @@ -75,35 +76,15 @@ router.post( ); router.get("/:dumpId", async (ctx) => { - let requestingUserId: string | 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 requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); const responseBody: APIResponse = { success: true, data: dump }; ctx.response.body = responseBody; }); router.get("/", async (ctx) => { - let requestingUserId: string | 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 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 requestingUserId = await parseOptionalAuth(ctx) ?? undefined; + const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = listDumps(page, limit, requestingUserId); const responseBody: APIResponse> = { success: true, @@ -120,7 +101,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { if (userId !== dump.userId) { throw new APIException( APIErrorCode.UNAUTHORIZED, - 401, + 403, "Not authorized to update dump", ); } @@ -164,7 +145,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { if (userId !== dump.userId) { throw new APIException( APIErrorCode.UNAUTHORIZED, - 401, + 403, "Not authorized to update dump", ); } @@ -182,7 +163,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => { if (userId !== dump.userId) { throw new APIException( APIErrorCode.UNAUTHORIZED, - 401, + 403, "Not authorized to update dump", ); } @@ -200,7 +181,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => { if (userId !== dump.userId) { throw new APIException( APIErrorCode.UNAUTHORIZED, - 401, + 403, "Not authorized to delete dump", ); } diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index caf23d3..e5b6320 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -1,5 +1,5 @@ import { Router } from "@oak/oak"; -import { verifyJWT } from "../lib/jwt.ts"; +import { parseOptionalAuth } from "../lib/auth.ts"; import { APIErrorCode, APIException, @@ -21,10 +21,9 @@ import { updatePlaylist, } from "../services/playlist-service.ts"; import { - detectImageMime, - MAX_IMAGE_SIZE, PLAYLIST_IMAGES_DIR, serveUploadedFile, + validateImageUpload, } from "../utils/upload.ts"; const router = new Router({ prefix: "/api/playlists" }); @@ -54,12 +53,7 @@ router.post("/", authMiddleware, async (ctx) => { // GET /api/playlists/:playlistId — optional auth router.get("/:playlistId", async (ctx) => { - let requestingUserId: string | null = null; - const authHeader = ctx.request.headers.get("Authorization"); - if (authHeader?.startsWith("Bearer ")) { - const payload = await verifyJWT(authHeader.substring(7)); - if (payload) requestingUserId = payload.userId; - } + const requestingUserId = await parseOptionalAuth(ctx); const playlist = getPlaylist(ctx.params.playlistId, requestingUserId); 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"); } - 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 mime = detectImageMime(data); - if (!mime) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "File content is not a recognised image (JPEG, PNG, GIF, WebP)", - ); - } + const mime = validateImageUpload(data); - // 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( ctx.params.playlistId, mime, ctx.state.user.userId, ); + const filePath = `${PLAYLIST_IMAGES_DIR}/${playlist.id}`; 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 }; }); diff --git a/api/routes/preview.ts b/api/routes/preview.ts index f817358..31619ff 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -3,6 +3,7 @@ import { fetchRichContent, isValidHttpUrl, } from "../services/rich-content-service.ts"; +import { APIErrorCode } from "../model/interfaces.ts"; const previewRouter = new Router(); @@ -10,7 +11,10 @@ previewRouter.get("/api/preview", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { 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; } const data = await fetchRichContent(url); diff --git a/api/routes/users.ts b/api/routes/users.ts index b74b0c6..b9cea55 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -9,8 +9,10 @@ import { type PaginatedData, } 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 { parseOptionalAuth } from "../lib/auth.ts"; +import { parsePagination } from "../lib/pagination.ts"; import { createUser, getUserById, @@ -19,6 +21,7 @@ import { updateUser, } from "../services/user-service.ts"; import { redeemInvite, validateInvite } from "../services/invite-service.ts"; +import { broadcastUserUpdated } from "../services/ws-service.ts"; import { getDumpsByUser, getVotedDumpsByUser, @@ -47,7 +50,11 @@ router.post("/register", async (ctx) => { const user = await createUser(body, inviterId); // 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({ userId: user.id, @@ -55,10 +62,11 @@ router.post("/register", async (ctx) => { isAdmin: user.isAdmin, }); + const { passwordHash: _, ...publicUser } = user; ctx.response.status = 201; ctx.response.body = { success: true, - data: { token: authToken, user }, + data: { token: authToken, user: publicUser }, }; }); @@ -92,11 +100,12 @@ router.post("/login", async (ctx) => { isAdmin: user.isAdmin, }); + const { passwordHash: _, ...publicUser } = user; ctx.response.body = { success: true, data: { token, - user, + user: publicUser, }, }; } catch (err) { @@ -146,6 +155,7 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => { } const updated = await updateUser(ctx.state.user.userId, body); const { passwordHash: _, ...publicUser } = updated; + broadcastUserUpdated(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) router.get("/:username/followed-playlists", (ctx) => { const user = getUserByUsername(ctx.params.username); - 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 { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit); ctx.response.body = { success: true, @@ -191,23 +191,8 @@ router.get("/:username/followed-playlists", (ctx) => { // Playlists by user (optional auth: include private only if requester === owner) router.get("/:username/playlists", async (ctx) => { const user = getUserByUsername(ctx.params.username); - let requestingUserId: string | null = null; - const authHeader = ctx.request.headers.get("Authorization"); - 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 requestingUserId = await parseOptionalAuth(ctx); + const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = listPlaylistsByUser( user.id, requestingUserId, @@ -234,23 +219,8 @@ router.get("/:username", (ctx) => { // Dumps posted by user (optional auth: owner sees their private dumps) router.get("/:username/dumps", async (ctx) => { const user = getUserByUsername(ctx.params.username); - let requestingUserId: string | null = null; - const authHeader = ctx.request.headers.get("Authorization"); - 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 requestingUserId = await parseOptionalAuth(ctx); + const { page, limit } = parsePagination(ctx.request.url.searchParams); const includePrivate = requestingUserId === user.id; const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate); 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) router.get("/:username/votes", async (ctx) => { const user = getUserByUsername(ctx.params.username); - let requestingUserId: string | null = null; - const authHeader = ctx.request.headers.get("Authorization"); - 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 requestingUserId = await parseOptionalAuth(ctx); + const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = getVotedDumpsByUser( user.id, page, diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 33d8258..d3977e0 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -4,6 +4,7 @@ import { broadcastPresence, broadcastVoteUpdate, getOnlineUsers, + handleClientPong, register, unregister, type WsClient, @@ -88,6 +89,9 @@ router.get("/ws", async (ctx) => { case "ping": socket.send(JSON.stringify({ type: "pong" })); break; + case "pong": + handleClientPong(client); + break; case "vote_cast": handleVote(client, msg.dumpId, "cast"); break; diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 6be4354..87cbf3c 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -374,22 +374,32 @@ export async function replaceFileDump( } 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 newSlug = makeSlug(file.name, dumpId); - db.prepare( - `UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`, - ).run( - file.name, - newSlug, - file.name, - file.type, - file.size, - comment ?? null, - now.toISOString(), - dumpId, - ); + try { + db.prepare( + `UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`, + ).run( + file.name, + newSlug, + file.name, + file.type, + file.size, + comment ?? null, + 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); return { diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index 85793ce..1c00234 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -2,6 +2,7 @@ import type { Notification, NotificationData, NotificationType, + UserDumpPostedData, } from "../model/interfaces.ts"; import { APIErrorCode, APIException } from "../model/interfaces.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 = ?;`, ).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) { - createNotification( + const id = crypto.randomUUID(); + placeholders.push("(?, ?, ?, ?, 0, ?, ?)"); + params.push( + id, row.follower_id, "user_dump_posted", - { dumperId, dumperUsername: posterRow.username, dumpId, dumpTitle }, - `dump:${dumpId}`, + dataJson, + 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( diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index cbfebe4..d65e84f 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -95,7 +95,8 @@ export function getPlaylist( // For public playlists (or when viewed by non-owner), filter out private dumps 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 INNER JOIN playlist_dumps pd ON d.id = pd.dump_id WHERE pd.playlist_id = ? diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index 25e1dbe..0c3b58a 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -80,10 +80,18 @@ export function extractOgTag( 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 { try { 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 { return false; } diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 49ac095..642441f 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -3,6 +3,7 @@ import type { Dump, OnlineUser, Playlist, + User, } from "../model/interfaces.ts"; export interface WsClient { @@ -11,6 +12,7 @@ export interface WsClient { username?: string; avatarMime?: string; avatarVersion?: number; + pongReceived?: boolean; } const clients = new Set(); @@ -151,6 +153,12 @@ export function broadcastPlaylistDumpsUpdated( }); } +export function broadcastUserUpdated(user: Omit): void { + for (const client of clients) { + send(client.socket, { type: "user_updated", user }); + } +} + export function broadcastCommentCreated(comment: Comment): void { for (const client of clients) { 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; setInterval(() => { @@ -181,7 +193,13 @@ setInterval(() => { clients.delete(client); 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" }); - // Schedule removal if no pong (tracked via heartbeat flag) } }, PING_INTERVAL); diff --git a/api/utils/upload.ts b/api/utils/upload.ts index 249fe9a..8d6eb53 100644 --- a/api/utils/upload.ts +++ b/api/utils/upload.ts @@ -1,4 +1,5 @@ import type { Context } from "@oak/oak"; +import { APIErrorCode, APIException } from "../model/interfaces.ts"; export const UPLOADS_DIR = "api/uploads"; export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; @@ -35,6 +36,26 @@ export function detectImageMime(data: Uint8Array): string | 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( ctx: Context, filePath: string, diff --git a/deno.json b/deno.json index 0506ce5..c8fa91d 100644 --- a/deno.json +++ b/deno.json @@ -1,9 +1,9 @@ { "tasks": { - "dev": "deno run -A npm:vite & deno run -A server:start", - "build": "deno run -A npm:vite build", - "server:start": "deno run -A --watch api/main.ts", - "serve": "deno run -A build && deno run -A server:start" + "dev": "deno run --env-file -A npm:vite & deno run -A server:start", + "build": "deno run --env-file -A npm:vite build", + "server:start": "deno run --env-file -A --watch api/main.ts", + "serve": "deno run --env-file -A build && deno run -A server:start" }, "nodeModulesDir": "auto", "compilerOptions": { diff --git a/src/App.css b/src/App.css index dfe11ce..ccb1007 100644 --- a/src/App.css +++ b/src/App.css @@ -927,6 +927,7 @@ body.has-player .fab-new { opacity: 0.75; display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } @@ -941,11 +942,12 @@ body.has-player .fab-new { } .dump-card--fading { - opacity: 0.28; + filter: brightness(0.65); } .dump-card--dismissing { opacity: 0; + filter: brightness(0.25); grid-template-rows: 0fr; pointer-events: none; } @@ -1154,8 +1156,6 @@ body.has-player .fab-new { } } -.profile-section {} - .profile-section ul { list-style: none; margin: 0; @@ -1981,7 +1981,8 @@ body.has-player .fab-new { transition: border-color 0.15s, grid-template-rows 0.32s ease, - opacity 0.25s ease; + opacity 0.25s ease, + filter 0.3s ease; } .playlist-card { @@ -1993,6 +1994,7 @@ body.has-player .fab-new { .dump-card-inner, .playlist-card-inner { overflow: hidden; + min-height: 0; display: flex; align-items: flex-start; gap: 0.75rem; @@ -2086,10 +2088,12 @@ body.has-player .fab-new { .dump-card-comment { -webkit-line-clamp: 3; + line-clamp: 3; } .playlist-card-description { -webkit-line-clamp: 2; + line-clamp: 3; } /* ── Shared card meta row ── */ diff --git a/src/App.tsx b/src/App.tsx index fa95e82..787e34e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,9 +24,9 @@ import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; import "./App.css"; function AppRoutes() { - const { token } = useAuth(); + const { token, user } = useAuth(); return ( - + diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 544c6be..9beae8c 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -10,16 +10,13 @@ export function AppHeader( const { user } = useAuth(); const navigate = useNavigate(); const headerRef = useRef(null); - const [_showFab, setShowFab] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); useEffect(() => { + // IntersectionObserver retained here to support a future floating action button const el = headerRef.current; if (!el) return; - const obs = new IntersectionObserver( - ([entry]) => setShowFab(!entry.isIntersecting), - { threshold: 0 }, - ); + const obs = new IntersectionObserver(() => {}, { threshold: 0 }); obs.observe(el); return () => obs.disconnect(); }, []); diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index 4f6c7d4..8eadd0b 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { Link } from "react-router"; import { API_URL } from "../config/api.ts"; import type { Comment, RawComment, User } from "../model.ts"; @@ -380,7 +380,7 @@ export function CommentThread({ const [submitting, setSubmitting] = useState(false); const [topLevelError, setTopLevelError] = useState(null); - const tree = buildTree(comments); + const tree = useMemo(() => buildTree(comments), [comments]); const roots = tree.get("root") ?? []; async function handleTopLevelSubmit(e?: React.FormEvent) { diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index da2c4ae..cfadeb1 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Link } from "react-router"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -15,9 +16,25 @@ function preprocessMentions(text: string): string { return text.replace(/(?["components"] = { + a: ({ href, children: linkChildren }) => { + if (href?.startsWith("/users/")) { + return {linkChildren}; + } + return ( + + {linkChildren} + + ); + }, +}; + export function Markdown( { children, className, inline = false }: MarkdownProps, ) { + const processed = useMemo(() => preprocessMentions(children), [children]); + return (
{ - if (href?.startsWith("/users/")) { - return {linkChildren}; - } - return ( - - {linkChildren} - - ); - }, - }} + components={MARKDOWN_COMPONENTS} > - {preprocessMentions(children)} + {processed}
); diff --git a/src/config/api.ts b/src/config/api.ts index 2c0e463..9bc8907 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -8,3 +8,20 @@ const serverPort = import.meta.env.VITE_SERVER_PORT || "8000"; export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`; 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; diff --git a/src/contexts/AuthProvider.tsx b/src/contexts/AuthProvider.tsx index 6908fc1..fc33633 100644 --- a/src/contexts/AuthProvider.tsx +++ b/src/contexts/AuthProvider.tsx @@ -18,7 +18,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { const stored = localStorage.getItem("authResponse"); 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)) { localStorage.removeItem("authResponse"); return null; diff --git a/src/contexts/FollowProvider.tsx b/src/contexts/FollowProvider.tsx index a853062..db018d5 100644 --- a/src/contexts/FollowProvider.tsx +++ b/src/contexts/FollowProvider.tsx @@ -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 { API_URL } from "../config/api.ts"; import { useAuth } from "../hooks/useAuth.ts"; @@ -21,23 +21,24 @@ export function FollowProvider({ children }: { children: ReactNode }) { setIsLoaded(false); return; } - let cancelled = false; + const controller = new AbortController(); fetch(`${API_URL}/api/follows/status`, { headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, }) .then((r) => r.json()) .then((body) => { - if (cancelled || !body.success) return; + if (!body.success) return; const status = body.data as FollowStatus; setFollowedUserIds(new Set(status.followedUserIds)); setFollowedPlaylistIds(new Set(status.followedPlaylistIds)); setIsLoaded(true); }) - .catch(() => { - if (!cancelled) setIsLoaded(true); + .catch((err) => { + if (err.name !== "AbortError") setIsLoaded(true); }); return () => { - cancelled = true; + controller.abort(); }; }, [token]); @@ -107,7 +108,7 @@ export function FollowProvider({ children }: { children: ReactNode }) { } }, [authFetch]); - const value: FollowContextValue = { + const value: FollowContextValue = useMemo(() => ({ followedUserIds, followedPlaylistIds, followUser, @@ -115,7 +116,15 @@ export function FollowProvider({ children }: { children: ReactNode }) { followPlaylist, unfollowPlaylist, isLoaded, - }; + }), [ + followedUserIds, + followedPlaylistIds, + followUser, + unfollowUser, + followPlaylist, + unfollowPlaylist, + isLoaded, + ]); return ( diff --git a/src/contexts/PlayerProvider.tsx b/src/contexts/PlayerProvider.tsx index fb11986..426708d 100644 --- a/src/contexts/PlayerProvider.tsx +++ b/src/contexts/PlayerProvider.tsx @@ -1,13 +1,15 @@ -import { useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { PlayerContext, type PlayerItem } from "./PlayerContext.ts"; export function PlayerProvider({ children }: { children: React.ReactNode }) { const [current, setCurrent] = useState(null); + const play = setCurrent; + const stop = useCallback(() => setCurrent(null), []); + const value = useMemo(() => ({ current, play, stop }), [current, play, stop]); + return ( - setCurrent(null) }} - > + {children} ); diff --git a/src/contexts/WSContext.ts b/src/contexts/WSContext.ts index 40b9b44..0d1a0db 100644 --- a/src/contexts/WSContext.ts +++ b/src/contexts/WSContext.ts @@ -5,6 +5,7 @@ import type { Notification, OnlineUser, Playlist, + PublicUser, } from "../model.ts"; export interface VoteEvent { @@ -28,6 +29,10 @@ export interface CommentEvent { commentId?: string; } +export interface UserEvent { + user: PublicUser; +} + export interface WSContextValue { onlineUsers: OnlineUser[]; voteCounts: Record; @@ -39,6 +44,7 @@ export interface WSContextValue { lastPlaylistEvent: PlaylistEvent | null; deletedPlaylistIds: Set; lastCommentEvent: CommentEvent | null; + lastUserEvent: UserEvent | null; unreadNotificationCount: number; lastNotification: Notification | null; castVote: (dumpId: string) => void; @@ -58,6 +64,7 @@ export const WSContext = createContext({ lastPlaylistEvent: null, deletedPlaylistIds: new Set(), lastCommentEvent: null, + lastUserEvent: null, unreadNotificationCount: 0, lastNotification: null, castVote: () => {}, diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx index 95a6a18..53029c5 100644 --- a/src/contexts/WSProvider.tsx +++ b/src/contexts/WSProvider.tsx @@ -3,12 +3,14 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, } from "react"; import { type CommentEvent, type PlaylistEvent, + type UserEvent, type VoteEvent, WSContext, type WSContextValue, @@ -22,23 +24,79 @@ import type { RawDump, RawNotification, RawPlaylist, + RawPublicUser, } from "../model.ts"; import { deserializeComment, deserializeDump, deserializeNotification, deserializePlaylist, + deserializePublicUser, } from "../model.ts"; interface WSProviderProps { children: ReactNode; token: string | null; + userId: string | null; } const MAX_BACKOFF = 30_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; + 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, +): 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, +): 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, +): msg is { playlistId: string; userId: string } { + return typeof msg.playlistId === "string" && typeof msg.userId === "string"; +} + +function isPlaylistDumpsUpdatedPayload( + msg: Record, +): msg is { playlistId: string; dumpIds: string[] } { + return typeof msg.playlistId === "string" && isStringArray(msg.dumpIds); +} + +function isCommentDeletedPayload( + msg: Record, +): 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([]); const [voteCounts, setVoteCounts] = useState>({}); const [myVotes, setMyVotes] = useState>(new Set()); @@ -55,6 +113,7 @@ export function WSProvider({ children, token }: WSProviderProps) { const [lastCommentEvent, setLastCommentEvent] = useState( null, ); + const [lastUserEvent, setLastUserEvent] = useState(null); const [unreadNotificationCount, setUnreadNotificationCount] = useState(0); const [lastNotification, setLastNotification] = useState( null, @@ -63,9 +122,11 @@ export function WSProvider({ children, token }: WSProviderProps) { // Refs to avoid stale closures in event handlers const voteCountsRef = useRef(voteCounts); const myVotesRef = useRef(myVotes); + const userIdRef = useRef(userId); useLayoutEffect(() => { voteCountsRef.current = voteCounts; myVotesRef.current = myVotes; + userIdRef.current = userId; }); const socketRef = useRef(null); @@ -103,41 +164,48 @@ export function WSProvider({ children, token }: WSProviderProps) { case "welcome": { backoff = 500; // reset backoff on successful connect - const users = msg.users as OnlineUser[]; - const votes = msg.myVotes as string[]; - setOnlineUsers(users); - setMyVotes(new Set(votes)); + if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) break; + setOnlineUsers(msg.users); + setMyVotes(new Set(msg.myVotes)); setUnreadNotificationCount( - (msg.unreadNotificationCount as number) ?? 0, + typeof msg.unreadNotificationCount === "number" + ? msg.unreadNotificationCount + : 0, ); break; } case "presence_update": - setOnlineUsers(msg.users as OnlineUser[]); + if (isOnlineUserArray(msg.users)) setOnlineUsers(msg.users); break; case "votes_update": { - const { dumpId, voteCount, voterId, action } = msg as { - dumpId: string; - voteCount: number; - voterId: string; - action: "cast" | "remove"; - }; + if (!isVotesUpdatePayload(msg)) break; + const { dumpId, voteCount, voterId, action } = msg; 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; } case "dump_created": { + if (!msg.dump || typeof msg.dump !== "object") break; const dump = deserializeDump(msg.dump as RawDump); setRecentDumps((prev) => [dump, ...prev]); break; } case "dump_updated": { + if (!msg.dump || typeof msg.dump !== "object") break; const dump = deserializeDump(msg.dump as RawDump); setLastDumpEvent(dump); // Un-delete if this dump was previously removed from the feed @@ -156,18 +224,16 @@ export function WSProvider({ children, token }: WSProviderProps) { } 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])); setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId)); break; } case "vote_ack": { - const { dumpId, action, voteCount } = msg as { - dumpId: string; - action: "cast" | "remove"; - voteCount: number; - }; + if (!isVoteAckPayload(msg)) break; + const { dumpId, action, voteCount } = msg; // Clear pending revert timeout const timeout = pendingRef.current.get(dumpId); if (timeout !== undefined) { @@ -188,6 +254,7 @@ export function WSProvider({ children, token }: WSProviderProps) { case "playlist_created": case "playlist_updated": { + if (!msg.playlist || typeof msg.playlist !== "object") break; const playlist = deserializePlaylist(msg.playlist as RawPlaylist); setLastPlaylistEvent({ type: msg.type === "playlist_created" ? "created" : "updated", @@ -198,20 +265,16 @@ export function WSProvider({ children, token }: WSProviderProps) { } case "playlist_deleted": { - const { playlistId, userId } = msg as { - playlistId: string; - userId: string; - }; + if (!isPlaylistDeletedPayload(msg)) break; + const { playlistId, userId } = msg; setDeletedPlaylistIds((prev) => new Set([...prev, playlistId])); setLastPlaylistEvent({ type: "deleted", playlistId, userId }); break; } case "playlist_dumps_updated": { - const { playlistId, dumpIds } = msg as { - playlistId: string; - dumpIds: string[]; - }; + if (!isPlaylistDumpsUpdatedPayload(msg)) break; + const { playlistId, dumpIds } = msg; setLastPlaylistEvent({ type: "dumps_updated", playlistId, @@ -220,7 +283,15 @@ export function WSProvider({ children, token }: WSProviderProps) { 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": { + if (!msg.comment || typeof msg.comment !== "object") break; const comment = deserializeComment(msg.comment as RawComment); setLastCommentEvent({ type: "created", @@ -231,15 +302,14 @@ export function WSProvider({ children, token }: WSProviderProps) { } case "comment_deleted": { - const { commentId, dumpId } = msg as { - commentId: string; - dumpId: string; - }; + if (!isCommentDeletedPayload(msg)) break; + const { commentId, dumpId } = msg; setLastCommentEvent({ type: "deleted", dumpId, commentId }); break; } case "comment_updated": { + if (!msg.comment || typeof msg.comment !== "object") break; const comment = deserializeComment(msg.comment as RawComment); setLastCommentEvent({ type: "updated", @@ -250,6 +320,7 @@ export function WSProvider({ children, token }: WSProviderProps) { } case "notification_created": { + if (!msg.notification || typeof msg.notification !== "object") break; const notification = deserializeNotification( msg.notification as RawNotification, ); @@ -361,7 +432,7 @@ export function WSProvider({ children, token }: WSProviderProps) { setUnreadNotificationCount(0); }, []); - const value: WSContextValue = { + const value: WSContextValue = useMemo(() => ({ onlineUsers, voteCounts, myVotes, @@ -372,13 +443,32 @@ export function WSProvider({ children, token }: WSProviderProps) { lastPlaylistEvent, deletedPlaylistIds, lastCommentEvent, + lastUserEvent, unreadNotificationCount, lastNotification, castVote, removeVote, injectDump, clearUnreadNotifications, - }; + }), [ + onlineUsers, + voteCounts, + myVotes, + recentDumps, + deletedDumpIds, + lastVoteEvent, + lastDumpEvent, + lastPlaylistEvent, + deletedPlaylistIds, + lastCommentEvent, + lastUserEvent, + unreadNotificationCount, + lastNotification, + castVote, + removeVote, + injectDump, + clearUnreadNotifications, + ]); return ( diff --git a/src/hooks/useDumpListSync.ts b/src/hooks/useDumpListSync.ts index a580f7d..3891910 100644 --- a/src/hooks/useDumpListSync.ts +++ b/src/hooks/useDumpListSync.ts @@ -2,28 +2,39 @@ import { useEffect, useLayoutEffect, useRef } from "react"; import type { Dump } from "../model.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: - * - deletedDumpIds: filters out dumps that were deleted or privatised. - * - lastDumpEvent: updates existing dumps in-place; optionally prepends - * new ones when `addFilter` returns true for them. - * - * @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). + * - deletedDumpIds growing → filter + * - lastDumpEvent "updated" → update in-place, or remove if now private + * - lastDumpEvent for a not-in-list dump → prepend if ownerId matches and visible */ export function useDumpListSync( setDumps: (fn: (prev: Dump[]) => Dump[]) => void, - addFilter?: (dump: Dump) => boolean, + options?: DumpListSyncOptions, ): void { const { deletedDumpIds, lastDumpEvent } = useWS(); // Keep refs up-to-date so closures in effects are never stale. const setDumpsRef = useRef(setDumps); - const addFilterRef = useRef(addFilter); + const optionsRef = useRef(options); useLayoutEffect(() => { setDumpsRef.current = setDumps; - addFilterRef.current = addFilter; + optionsRef.current = options; }); useEffect(() => { @@ -35,17 +46,27 @@ export function useDumpListSync( useEffect(() => { if (!lastDumpEvent) return; + const { isOwner, ownerId, skipReinsert } = optionsRef.current ?? {}; + const dump = lastDumpEvent; + setDumpsRef.current((prev) => { - const idx = prev.findIndex((d) => d.id === lastDumpEvent.id); + const idx = prev.findIndex((d) => d.id === dump.id); + 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]; - next[idx] = lastDumpEvent; + next[idx] = dump; return next; } - if (addFilterRef.current?.(lastDumpEvent)) { - return [lastDumpEvent, ...prev]; - } - return prev; + + // Dump not in list: only re-insert when ownerId is set and matches. + if (skipReinsert || !ownerId) return prev; + if (dump.userId !== ownerId) return prev; + if (dump.isPrivate && !isOwner) return prev; + return [dump, ...prev]; }); }, [lastDumpEvent]); } diff --git a/src/hooks/useFading.ts b/src/hooks/useFading.ts new file mode 100644 index 0000000..41b36e3 --- /dev/null +++ b/src/hooks/useFading.ts @@ -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 + >({}); + const cancels = useRef 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 }; +} diff --git a/src/hooks/usePlaylistListSync.ts b/src/hooks/usePlaylistListSync.ts index 0f1b217..9c99dc1 100644 --- a/src/hooks/usePlaylistListSync.ts +++ b/src/hooks/usePlaylistListSync.ts @@ -15,6 +15,12 @@ interface PlaylistListSyncOptions { * (followed membership is managed separately), but still update/remove. */ 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 (ownerId && ev.playlist.userId !== ownerId) 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]; } // 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); } + 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; }); }, [lastPlaylistEvent]); diff --git a/src/hooks/usePositionAwareSync.ts b/src/hooks/usePositionAwareSync.ts new file mode 100644 index 0000000..fcf2ea4 --- /dev/null +++ b/src/hooks/usePositionAwareSync.ts @@ -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( + items: T[], + setItems: (fn: (prev: T[]) => T[]) => void, + lastEvent: T | null, + isRemoving: (item: T) => boolean, + shouldReinsert: (item: T) => boolean, +): void { + const itemsRef = useRef(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>(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]); +} diff --git a/src/model.ts b/src/model.ts index 9d0ef7b..9eb7cb1 100644 --- a/src/model.ts +++ b/src/model.ts @@ -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 { return { ...raw, diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index e259015..64b5e30 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -57,13 +57,16 @@ export function Dump() { useEffect(() => { if (!selectedDump) return; + const controller = new AbortController(); 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.success && setOp(deserializePublicUser(r.data))) .catch(() => {}); - return; + return () => controller.abort(); } setDumpState({ status: "loading" }); @@ -73,6 +76,7 @@ export function Dump() { try { const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store", + signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }); const apiResponse = await res.json(); @@ -82,14 +86,18 @@ export function Dump() { const dump: Dump = deserializeDump(apiResponse.data); 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.success && setOp(deserializePublicUser(r.data))) .catch(() => {}); } catch (err) { + if ((err as Error).name === "AbortError") return; setDumpState({ status: "error", error: friendlyFetchError(err) }); } })(); + return () => controller.abort(); }, [selectedDump, preloaded]); useEffect(() => { @@ -105,7 +113,9 @@ export function Dump() { // Fetch comments when dump loads useEffect(() => { if (!selectedDump) return; + const controller = new AbortController(); fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, { + signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((r) => r.json()) @@ -115,6 +125,7 @@ export function Dump() { } }) .catch(() => {}); + return () => controller.abort(); }, [selectedDump, token]); // Scroll to and highlight a comment when navigating to #comment-{id} @@ -133,8 +144,11 @@ export function Dump() { }, [comments, location.hash]); // 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(() => { - if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return; + if (!lastCommentEvent || !loadedDumpId || lastCommentEvent.dumpId !== loadedDumpId) return; if (lastCommentEvent.type === "created" && lastCommentEvent.comment) { setComments((prev) => { if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) { @@ -161,7 +175,7 @@ export function Dump() { ) ); } - }, [lastCommentEvent, selectedDump]); + }, [lastCommentEvent, loadedDumpId]); if (dumpState.status === "loading") { return ( diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index b70a440..04e0376 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, } from "react"; @@ -11,11 +12,12 @@ import { Avatar } from "../components/Avatar.tsx"; import { DumpCard } from "../components/DumpCard.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 { deserializeDump, type Dump, + hydrateDump, type PaginatedData, type RawDump, type User, @@ -29,12 +31,6 @@ import { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.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 = | { status: "loading" } | { status: "error"; error: string } @@ -210,11 +206,13 @@ export function Index() { useEffect(() => { if (mainFetchDone.current || cached) return; mainFetchDone.current = true; + const controller = new AbortController(); (async () => { try { 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}` } : {}, }, ); @@ -229,12 +227,17 @@ export function Index() { loadingMore: false, }); } catch (err) { + if ((err as Error).name === "AbortError") return; setDumpsState({ status: "error", error: friendlyFetchError(err), }); } })(); + return () => { + mainFetchDone.current = false; + controller.abort(); + }; }, [cached, token]); // ── Followed feeds fetch (lazy, on first tab open) ── @@ -252,7 +255,7 @@ export function Index() { loadingMore: false, }); } 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}` }, }) .then((r) => r.json()) @@ -286,7 +289,7 @@ export function Index() { }); } else { 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}` }, }, @@ -312,7 +315,7 @@ export function Index() { } }, [ tab, - user?.id, + user, token, cachedFollowedUsers, cachedFollowedPlaylists, @@ -331,7 +334,7 @@ export function Index() { setDumpsState((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}` } : {}, }) .then((r) => r.json()) @@ -368,7 +371,7 @@ export function Index() { s.status === "loaded" ? { ...s, loadingMore: true } : s ); 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}` }, }, @@ -407,7 +410,7 @@ export function Index() { s.status === "loaded" ? { ...s, loadingMore: true } : s ); 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}` }, }, @@ -529,7 +532,7 @@ export function Index() { const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : []; 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] .filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId); diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 60276b1..b613c1b 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; 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 { ErrorCard } from "../components/ErrorCard.tsx"; import { Tooltip } from "../components/Tooltip.tsx"; @@ -22,8 +22,6 @@ import { deserializeNotification } from "../model.ts"; import { PageShell } from "../components/PageShell.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; -const PAGE_SIZE = 30; - type State = | { status: "loading" } | { status: "error"; error: string } @@ -219,7 +217,7 @@ export function Notifications() { useEffect(() => { // 1. Fetch with original read state so unread items are highlighted // 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((body) => { if (!body.success) throw new Error("Failed to load"); @@ -270,7 +268,7 @@ export function Notifications() { const nextPage = state.page + 1; setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); 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((body) => { diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 5d67436..ba8aa8d 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -92,10 +92,16 @@ export function PlaylistDetail() { cancels.current.forEach((c) => c()); }, []); + const fetchAbortRef = useRef(null); + const fetchPlaylist = () => { if (!playlistId) return; + fetchAbortRef.current?.abort(); + const controller = new AbortController(); + fetchAbortRef.current = controller; setState({ status: "loading" }); fetch(`${API_URL}/api/playlists/${playlistId}`, { + signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((r) => { @@ -122,6 +128,7 @@ export function PlaylistDetail() { cancels.current.clear(); }) .catch((err) => { + if (err.name === "AbortError") return; setState({ status: "error", error: friendlyFetchError(err), @@ -131,6 +138,7 @@ export function PlaylistDetail() { useEffect(() => { fetchPlaylist(); + return () => fetchAbortRef.current?.abort(); }, [playlistId]); // Start the cooldown→dismissing→gone sequence for a dump being removed. @@ -465,8 +473,10 @@ export function PlaylistDetail() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - title: editTitle, - description: editDescription || undefined, + ...(editTitle !== state.playlist.title ? { title: editTitle } : {}), + ...(editDescription !== (state.playlist.description ?? "") + ? { description: editDescription || null } + : {}), isPublic: editIsPublic, }), }, diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index eb45e82..a797326 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -7,13 +7,14 @@ import { } from "react"; 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 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 { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; +import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; import { Avatar } from "../components/Avatar.tsx"; @@ -22,10 +23,6 @@ import { DumpCreateModal } from "../components/DumpCreateModal.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; -const PAGE_SIZE = 20; -const hydrateDump = (raw: Dump): Dump => - deserializeDump(raw as unknown as RawDump); - type State = | { status: "loading" } | { status: "error"; error: string } @@ -41,7 +38,7 @@ type State = export function UserDumps() { const { username } = useParams(); const { user: me, token } = useAuth(); - const { voteCounts, myVotes, castVote, removeVote } = useWS(); + const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS(); const { cached, saveState } = useFeedCache( `feed:user-dumps-full:${username ?? ""}`, hydrateDump, @@ -56,19 +53,27 @@ export function UserDumps() { const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => { setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }); }, []); - const addFilter = useCallback((dump: Dump): boolean => { - if (!profileUserId) return false; - if (dump.userId !== profileUserId) return false; - return isOwnProfile || !dump.isPrivate; - }, [profileUserId, isOwnProfile]); - useDumpListSync(setDumps, addFilter); + const dumpItems = state.status === "loaded" ? state.dumps : []; + usePositionAwareSync( + dumpItems, + setDumps, + lastDumpEvent, + (d) => d.isPrivate, + (d) => !d.isPrivate && d.userId === profileUserId, + ); + useDumpListSync(setDumps, { + ownerId: profileUserId ?? undefined, + isOwner: isOwnProfile, + skipReinsert: true, + }); useEffect(() => { if (!username) return; setState({ status: "loading" }); + const controller = new AbortController(); if (cached) { - fetch(`${API_URL}/api/users/${username}`) + fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("User not found"); @@ -81,23 +86,21 @@ export function UserDumps() { loadingMore: false, }); }) - .catch((err) => - setState({ - status: "error", - error: friendlyFetchError(err), - }) - ); - return; + .catch((err) => { + if (err.name === "AbortError") return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + return () => controller.abort(); } const authHeaders: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; Promise.all([ - fetch(`${API_URL}/api/users/${username}`), + fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }), fetch( - `${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, - { headers: authHeaders }, + `${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`, + { headers: authHeaders, signal: controller.signal }, ), ]) .then(([userRes, dumpsRes]) => @@ -117,12 +120,11 @@ export function UserDumps() { loadingMore: false, }); }) - .catch((err) => - setState({ - status: "error", - error: friendlyFetchError(err), - }) - ); + .catch((err) => { + if (err.name === "AbortError") return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + return () => controller.abort(); }, [username]); const loadMore = useCallback(() => { @@ -133,7 +135,7 @@ export function UserDumps() { const nextPage = state.page + 1; setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); 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}` } : {} }, ) .then((r) => r.json()) diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index 7fc24c8..191b78c 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -7,7 +7,7 @@ import { } from "react"; 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 type { PaginatedData, @@ -15,9 +15,11 @@ import type { PublicUser, RawPlaylist, } from "../model.ts"; -import { deserializePlaylist, deserializePublicUser } from "../model.ts"; +import { deserializePlaylist, deserializePublicUser, hydratePlaylist } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; +import { useWS } from "../hooks/useWS.ts"; import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts"; +import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; import { Avatar } from "../components/Avatar.tsx"; @@ -27,10 +29,6 @@ import { ConfirmModal } from "../components/ConfirmModal.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; -const PAGE_SIZE = 20; -const hydratePlaylist = (raw: Playlist): Playlist => - deserializePlaylist(raw as unknown as RawPlaylist); - interface PlaylistFeed { items: Playlist[]; hasMore: boolean; @@ -55,6 +53,7 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed { export function UserPlaylists() { const { username } = useParams(); const { user: me, authFetch, token } = useAuth(); + const { lastPlaylistEvent } = useWS(); const { cached: cachedCreated, saveState: saveCreated } = useFeedCache< Playlist @@ -82,9 +81,21 @@ export function UserPlaylists() { : { ...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, { isOwner: isOwnProfile, ownerId: profileUserId ?? undefined, + skipReinsert: true, }); const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { @@ -99,13 +110,14 @@ export function UserPlaylists() { useEffect(() => { if (!username) return; setState({ status: "loading" }); + const controller = new AbortController(); const authHeaders: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; if (cachedCreated && cachedFollowed) { - fetch(`${API_URL}/api/users/${username}`) + fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("User not found"); @@ -126,23 +138,22 @@ export function UserPlaylists() { }, }); }) - .catch((err) => - setState({ - status: "error", - error: friendlyFetchError(err), - }) - ); - return; + .catch((err) => { + if (err.name === "AbortError") return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + return () => controller.abort(); } Promise.all([ - fetch(`${API_URL}/api/users/${username}`), + fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }), fetch( - `${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, - { headers: authHeaders }, + `${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, + { headers: authHeaders, signal: controller.signal }, ), 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]) => @@ -169,12 +180,11 @@ export function UserPlaylists() { ), }); }) - .catch((err) => - setState({ - status: "error", - error: friendlyFetchError(err), - }) - ); + .catch((err) => { + if (err.name === "AbortError") return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + return () => controller.abort(); }, [username]); const loadMoreCreated = useCallback(() => { @@ -189,7 +199,7 @@ export function UserPlaylists() { : s ); 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}` } : {} }, ) .then((r) => r.json()) @@ -230,7 +240,7 @@ export function UserPlaylists() { : s ); 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((body) => { diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index 284d4a7..c6b82ca 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -7,13 +7,15 @@ import React, { } from "react"; 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 { deserializeAuthResponse, deserializeDump, deserializePublicUser, deserializeUser, + hydrateDump, + hydratePlaylist, type RawDump, type RawUser, } from "../model.ts"; @@ -26,7 +28,9 @@ import { PageError } from "../components/PageError.tsx"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; +import { useFading } from "../hooks/useFading.ts"; import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts"; +import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import type { Playlist, RawPlaylist } from "../model.ts"; import { deserializePlaylist } from "../model.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; @@ -37,8 +41,6 @@ import { friendlyFetchError } from "../utils/apiError.ts"; import { TextEditor } from "../components/TextEditor.tsx"; import { Markdown } from "../components/Markdown.tsx"; -const PAGE_SIZE = 20; - function InviteButton() { const { authFetch } = useAuth(); const [inviteUrl, setInviteUrl] = useState(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 { items: T[]; hasMore: boolean; @@ -125,6 +122,8 @@ export function UserPublicProfile() { myVotes, lastVoteEvent, lastDumpEvent, + lastPlaylistEvent, + lastUserEvent, castVote, removeVote, } = useWS(); @@ -149,89 +148,47 @@ export function UserPublicProfile() { const profileUserId = state.status === "loaded" ? state.user.id : null; const isOwnProfile = me?.id === profileUserId; - const removedDumpPositionsRef = useRef>(new Map()); - const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => { - setState((s) => { - if (s.status !== "loaded") return s; - const prev = s.dumps.items; - const next = fn(prev); - 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 } }; - }); + setState((s) => + s.status !== "loaded" + ? s + : { ...s, dumps: { ...s.dumps, items: fn(s.dumps.items) } } + ); }, []); - // No addFilter — insertion at correct position is handled by the effect below. - useDumpListSync(setDumps); - - const [profileVotedIds, setProfileVotedIds] = useState>( - new Set(), + const dumpItems = state.status === "loaded" ? state.dumps.items : []; + usePositionAwareSync( + dumpItems, + setDumps, + 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>(new Map()); // 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). const withdrawnVoteIdsRef = useRef>(new Set()); const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => { - setState((s) => { - if (s.status !== "loaded") return s; - const prev = s.votes.items; - const next = fn(prev); - 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 } }; - }); + setState((s) => + s.status !== "loaded" + ? s + : { ...s, votes: { ...s.votes, items: fn(s.votes.items) } } + ); }, []); - useDumpListSync(setVotes); - - // Re-insert a vote-list dump at its original position after private→public. - // Skip dumps whose vote was explicitly withdrawn (those were removed intentionally). - useEffect(() => { - if (!lastDumpEvent || lastDumpEvent.isPrivate) return; - const dump = lastDumpEvent; - if (withdrawnVoteIdsRef.current.has(dump.id)) return; - const savedIdx = removedVotePositionsRef.current.get(dump.id); - 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 voteItems = state.status === "loaded" ? state.votes.items : []; + usePositionAwareSync( + voteItems, + setVotes, + lastDumpEvent, + (d) => d.isPrivate, + (d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id), + ); + useDumpListSync(setVotes, { skipReinsert: true }); const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { setState((s) => @@ -240,11 +197,33 @@ export function UserPublicProfile() { : { ...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, { isOwner: isOwnProfile, 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 [avatarError, setAvatarError] = useState(null); const fileInputRef = useRef(null); @@ -258,18 +237,21 @@ export function UserPublicProfile() { useEffect(() => { if (!username) return; setState({ status: "loading" }); + prevMyVotesRef.current = null; + const controller = new AbortController(); const allCached = cachedDumps && cachedVotes && cachedPlaylists; if (allCached) { // 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((body) => { if (!body.success) throw new Error("User not found"); + const profileUser = deserializePublicUser(body.data); setState({ status: "loaded", - user: deserializePublicUser(body.data), + user: profileUser, dumps: { items: cachedDumps.items, hasMore: cachedDumps.hasMore, @@ -289,15 +271,12 @@ export function UserPublicProfile() { loadingMore: false, }, }); - setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id))); }) - .catch((err) => - setState({ - status: "error", - error: friendlyFetchError(err), - }) - ); - return; + .catch((err) => { + if (err.name === "AbortError") return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + return () => controller.abort(); } (async () => { @@ -306,18 +285,20 @@ export function UserPublicProfile() { ? { Authorization: `Bearer ${token}` } : {}; const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([ - fetch(`${API_URL}/api/users/${username}`), + fetch(`${API_URL}/api/users/${username}`, { + signal: controller.signal, + }), fetch( - `${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, - { headers: authHeaders }, + `${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`, + { headers: authHeaders, signal: controller.signal }, ), fetch( - `${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, - { headers: authHeaders }, + `${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`, + { headers: authHeaders, signal: controller.signal }, ), fetch( - `${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, - { headers: authHeaders }, + `${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, + { headers: authHeaders, signal: controller.signal }, ), ]); @@ -347,10 +328,11 @@ export function UserPublicProfile() { ? dumpsBody.data : { items: [], total: 0, hasMore: false }; + const profileUser = deserializePublicUser(userBody.data); const voteItems = votesData.items.map(deserializeDump); setState({ status: "loaded", - user: deserializePublicUser(userBody.data), + user: profileUser, dumps: initialList( dumpsData.items.map(deserializeDump), dumpsData.hasMore, @@ -361,20 +343,20 @@ export function UserPublicProfile() { playlistsData.hasMore, ), }); - setProfileVotedIds(new Set(voteItems.map((d) => d.id))); } catch (err) { + if ((err as Error).name === "AbortError") return; setState({ status: "error", error: friendlyFetchError(err), }); } })(); + return () => controller.abort(); }, [username]); - // Own profile: keep profileVotedIds in sync with myVotes + // Own profile: prepend dumps newly voted by the user to the preview list useEffect(() => { if (!profileUserId || me?.id !== profileUserId) return; - setProfileVotedIds(new Set(myVotes)); if (prevMyVotesRef.current === null) { prevMyVotesRef.current = new Set(myVotes); return; @@ -400,35 +382,28 @@ export function UserPublicProfile() { if (!lastVoteEvent || !profileUserId) return; const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; - const isOwnProfile = me?.id === profileUserId; if (action === "remove") { - if (!isOwnProfile) { - setProfileVotedIds((prev) => { - const n = new Set(prev); - n.delete(dumpId); - return n; - }); - } + // Keep dump in state.votes.items as a ghost — UpvotedDumpList drives + // its own votedIds + fading state and will animate the removal. withdrawnVoteIdsRef.current.add(dumpId); - setVotes((prev) => prev.filter((d) => d.id !== dumpId)); } else { withdrawnVoteIdsRef.current.delete(dumpId); - if (!isOwnProfile) { - setProfileVotedIds((prev) => new Set([...prev, dumpId])); - } fetch(`${API_URL}/api/dumps/${dumpId}`) .then((r) => r.json()) .then((body) => { if (!body.success) return; const dump = deserializeDump(body.data); setState((s) => { - if ( - s.status !== "loaded" || - s.votes.items.some((d) => d.id === dumpId) - ) { - return s; + if (s.status !== "loaded") return s; + const idx = s.votes.items.findIndex((d) => d.id === dumpId); + if (idx !== -1) { + // Ghost re-voted: update in-place. + const items = [...s.votes.items]; + items[idx] = dump; + return { ...s, votes: { ...s.votes, items } }; } + // First-time vote: prepend. return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] }, @@ -437,7 +412,7 @@ export function UserPublicProfile() { }) .catch(() => {}); } - }, [lastVoteEvent, me, profileUserId]); + }, [lastVoteEvent, profileUserId]); // Save scroll position + loaded state to sessionStorage on scroll useEffect(() => { @@ -465,6 +440,19 @@ export function UserPublicProfile() { }; }, [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 const scrollRestored = useRef(false); useLayoutEffect(() => { @@ -734,9 +722,10 @@ export function UserPublicProfile() { /> ; + profileUserId: string | null; + isOwnProfile: boolean; voteCounts: Record; myVotes: Set; canVote: boolean; @@ -884,84 +875,46 @@ function UpvotedDumpList( viewAllHref: string; }, ) { - const [fading, setFading] = useState< - Record - >({}); - const cancels = useRef void>>(new Map()); - const prevVotedIds = useRef | null>(null); + const { myVotes: wsMyVotes, lastVoteEvent } = useWS(); + const { fading, startFading, cancelFading } = useFading(); - useEffect(() => () => { - cancels.current.forEach((c) => c()); - }, []); + // votedIds is managed locally so setVotedIds + startFading/cancelFading can + // 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 | null>(null); + // Own profile: sync votedIds with myVotes; start/cancel fading in same batch. useEffect(() => { - if (prevVotedIds.current === null) { - prevVotedIds.current = new Set(votedIds); + if (!profileUserId || !isOwnProfile) return; + if (prevMyVotesRef.current === null) { + setVotedIds(new Set(wsMyVotes)); + prevMyVotesRef.current = new Set(wsMyVotes); 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; - - 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()); - } + // Non-own profile: sync votedIds with WS vote events for the profile user. + useEffect(() => { + if (!lastVoteEvent || !profileUserId || isOwnProfile) return; + const { dumpId, voterId, action } = lastVoteEvent; + if (voterId !== profileUserId) return; + if (action === "remove") { + setVotedIds((prev) => { const n = new Set(prev); n.delete(dumpId); return n; }); + startFading(dumpId); + } else { + setVotedIds((prev) => new Set([...prev, dumpId])); + cancelFading(dumpId); } + }, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]); - for (const id of votedIds) { - 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 - ); + const visibleDumps = dumps.filter((d) => votedIds.has(d.id) || d.id in fading); return (
diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index 1157b2b..ca8d12d 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -7,13 +7,14 @@ import { } from "react"; 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 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 { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; +import { useFading } from "../hooks/useFading.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; import { Avatar } from "../components/Avatar.tsx"; @@ -21,10 +22,6 @@ import { DumpCard } from "../components/DumpCard.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; -const PAGE_SIZE = 20; -const hydrateDump = (raw: Dump): Dump => - deserializeDump(raw as unknown as RawDump); - type State = | { status: "loading" } | { status: "error"; error: string } @@ -54,26 +51,19 @@ export function UserUpvoted() { useDumpListSync(setVotesDumps); const [votedIds, setVotedIds] = useState>(new Set()); - const [fading, setFading] = useState< - Record - >({}); - const cancels = useRef void>>(new Map()); - const prevVotedIds = useRef | null>(null); + const { fading, startFading, cancelFading, cancelAll } = useFading(); const prevMyVotesRef = useRef | null>(null); - useEffect(() => () => { - cancels.current.forEach((c) => c()); - }, []); - useEffect(() => { if (!username) return; setState({ status: "loading" }); + cancelAll(); setVotedIds(new Set()); - prevVotedIds.current = null; prevMyVotesRef.current = null; + const controller = new AbortController(); if (cached) { - fetch(`${API_URL}/api/users/${username}`) + fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("User not found"); @@ -88,23 +78,21 @@ export function UserUpvoted() { }); setVotedIds(voteIds); }) - .catch((err) => - setState({ - status: "error", - error: friendlyFetchError(err), - }) - ); - return; + .catch((err) => { + if (err.name === "AbortError") return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + return () => controller.abort(); } const authHeaders: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; Promise.all([ - fetch(`${API_URL}/api/users/${username}`), + fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }), fetch( - `${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, - { headers: authHeaders }, + `${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`, + { headers: authHeaders, signal: controller.signal }, ), ]) .then(([userRes, votesRes]) => @@ -126,37 +114,32 @@ export function UserUpvoted() { }); setVotedIds(new Set(voteItems.map((d) => d.id))); }) - .catch((err) => - setState({ - status: "error", - error: friendlyFetchError(err), - }) - ); + .catch((err) => { + if (err.name === "AbortError") return; + setState({ status: "error", error: friendlyFetchError(err) }); + }); + return () => controller.abort(); }, [username]); 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(() => { if (!profileUserId || me?.id !== profileUserId) return; - setVotedIds(new Set(myVotes)); if (prevMyVotesRef.current === null) { + // First sync after load: initialize without animating the diff. + setVotedIds(new Set(myVotes)); prevMyVotesRef.current = new Set(myVotes); return; } const prev = prevMyVotesRef.current; - setState((s) => { - if (s.status !== "loaded") return s; - const voteIdSet = new Set(s.votes.map((d) => d.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; - }); + setVotedIds(new Set(myVotes)); + for (const id of prev) { if (!myVotes.has(id)) startFading(id); } + for (const id of myVotes) { if (!prev.has(id)) cancelFading(id); } prevMyVotesRef.current = new Set(myVotes); - }, [myVotes, me, profileUserId]); + }, [myVotes, me, profileUserId, startFading, cancelFading]); // WS vote events useEffect(() => { @@ -170,8 +153,11 @@ export function UserUpvoted() { n.delete(dumpId); return n; }); + // Start fading in same batch so visibleDumps never has a gap render. + startFading(dumpId); } else { setVotedIds((prev) => new Set([...prev, dumpId])); + cancelFading(dumpId); fetch(`${API_URL}/api/dumps/${dumpId}`) .then((r) => r.json()) .then((body) => { @@ -186,73 +172,8 @@ export function UserUpvoted() { }) .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(() => { if ( @@ -262,7 +183,7 @@ export function UserUpvoted() { const nextPage = state.page + 1; setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); 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}` } : {} }, ) .then((r) => r.json()) @@ -342,9 +263,7 @@ export function UserUpvoted() { } const { profileUser, votes, hasMore, loadingMore } = state; - const visibleDumps = votes.filter((d) => - votedIds.has(d.id) || d.id in fading - ); + const visibleDumps = votes.filter((d) => votedIds.has(d.id) || d.id in fading); return (