v3: added comment likes, added votes/likes list view, added db migrations mechanism, updated project dependencies
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

This commit is contained in:
khannurien
2026-06-21 14:17:56 +00:00
parent 888aae45cf
commit 7afb5d3f07
25 changed files with 1475 additions and 840 deletions

View File

@@ -15,9 +15,11 @@ import {
ORPHANED_ATTACHMENTS_RETENTION_HOURS, ORPHANED_ATTACHMENTS_RETENTION_HOURS,
UNUSED_INVITES_RETENTION_DAYS, UNUSED_INVITES_RETENTION_DAYS,
} from "../config.ts"; } from "../config.ts";
import { runMigrations } from "../sql/migrate.ts";
export const db = new DatabaseSync(DB_PATH); export const db = new DatabaseSync(DB_PATH);
db.exec("PRAGMA foreign_keys = ON;"); db.exec("PRAGMA foreign_keys = ON;");
runMigrations(db);
// Purge expired/used password reset tokens on startup // Purge expired/used password reset tokens on startup
db.prepare( db.prepare(
@@ -236,6 +238,7 @@ export interface CommentRow {
created_at: string; created_at: string;
updated_at: string | null; updated_at: string | null;
deleted: number; deleted: number;
like_count: number;
author_username: string; author_username: string;
author_avatar_mime: string | null; author_avatar_mime: string | null;
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
@@ -255,6 +258,7 @@ export function isCommentRow(
"updated_at" in obj && "updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) && (typeof obj.updated_at === "string" || obj.updated_at === null) &&
"deleted" in obj && typeof obj.deleted === "number" && "deleted" in obj && typeof obj.deleted === "number" &&
"like_count" in obj && typeof obj.like_count === "number" &&
"author_username" in obj && typeof obj.author_username === "string" && "author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj && "author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" || (typeof obj.author_avatar_mime === "string" ||
@@ -271,6 +275,7 @@ export function commentRowToApi(row: CommentRow): Comment {
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
deleted: Boolean(row.deleted), deleted: Boolean(row.deleted),
likeCount: row.like_count,
authorUsername: row.author_username, authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined, authorAvatarMime: row.author_avatar_mime ?? undefined,
}; };

View File

@@ -255,6 +255,7 @@ export interface Comment {
createdAt: Date; createdAt: Date;
updatedAt?: Date; updatedAt?: Date;
deleted: boolean; deleted: boolean;
likeCount: number;
authorUsername: string; authorUsername: string;
authorAvatarMime?: string; authorAvatarMime?: string;
} }
@@ -469,11 +470,23 @@ export interface VoteRemoveMessage {
dumpId: string; dumpId: string;
} }
export interface CommentLikeCastMessage {
type: "comment_like_cast";
commentId: string;
}
export interface CommentLikeRemoveMessage {
type: "comment_like_remove";
commentId: string;
}
export type ClientToServerMessage = export type ClientToServerMessage =
| PingMessage | PingMessage
| PongMessage | PongMessage
| VoteCastMessage | VoteCastMessage
| VoteRemoveMessage; | VoteRemoveMessage
| CommentLikeCastMessage
| CommentLikeRemoveMessage;
// ── Server → Client ────────────────────────────────────────────────────────── // ── Server → Client ──────────────────────────────────────────────────────────
@@ -488,6 +501,7 @@ export interface WelcomeMessage {
type: "welcome"; type: "welcome";
users: OnlineUser[]; users: OnlineUser[];
myVotes: string[]; myVotes: string[];
myCommentLikes: string[];
unreadNotificationCount: number; unreadNotificationCount: number;
} }
@@ -511,6 +525,21 @@ export interface VoteAckMessage {
voteCount: number; voteCount: number;
} }
export interface CommentLikesUpdateMessage {
type: "comment_likes_update";
commentId: string;
likeCount: number;
likerId: string;
action: "cast" | "remove";
}
export interface CommentLikeAckMessage {
type: "comment_like_ack";
commentId: string;
action: "cast" | "remove";
likeCount: number;
}
export interface DumpCreatedMessage { export interface DumpCreatedMessage {
type: "dump_created"; type: "dump_created";
dump: Dump; dump: Dump;
@@ -589,6 +618,8 @@ export type ServerToClientMessage =
| PresenceUpdateMessage | PresenceUpdateMessage
| VotesUpdateMessage | VotesUpdateMessage
| VoteAckMessage | VoteAckMessage
| CommentLikesUpdateMessage
| CommentLikeAckMessage
| DumpCreatedMessage | DumpCreatedMessage
| DumpUpdatedMessage | DumpUpdatedMessage
| DumpDeletedMessage | DumpDeletedMessage
@@ -624,7 +655,8 @@ export type NotificationType =
| "playlist_dump_added" | "playlist_dump_added"
| "dump_upvoted" | "dump_upvoted"
| "user_mentioned" | "user_mentioned"
| "dump_commented"; | "dump_commented"
| "comment_liked";
export interface PlaylistFollowedData { export interface PlaylistFollowedData {
followerId: string; followerId: string;
@@ -676,6 +708,14 @@ export interface DumpCommentedData {
dumpTitle: string; dumpTitle: string;
} }
export interface CommentLikedData {
likerId: string;
likerUsername: string;
commentId: string;
dumpId: string;
dumpTitle: string;
}
export type NotificationData = export type NotificationData =
| PlaylistFollowedData | PlaylistFollowedData
| UserFollowedData | UserFollowedData
@@ -683,7 +723,8 @@ export type NotificationData =
| PlaylistDumpAddedData | PlaylistDumpAddedData
| DumpUpvotedData | DumpUpvotedData
| UserMentionedData | UserMentionedData
| DumpCommentedData; | DumpCommentedData
| CommentLikedData;
export interface Notification { export interface Notification {
id: string; id: string;

View File

@@ -9,12 +9,15 @@ import {
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts"; import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { import {
createComment, createComment,
deleteComment, deleteComment,
getCommentDumpId,
getComments, getComments,
updateComment, updateComment,
} from "../services/comment-service.ts"; } from "../services/comment-service.ts";
import { getCommentLikers } from "../services/comment-like-service.ts";
import { getDump } from "../services/dump-service.ts"; import { getDump } from "../services/dump-service.ts";
import { import {
broadcastCommentCreated, broadcastCommentCreated,
@@ -83,6 +86,27 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
// GET /api/comments/:commentId/likers — optional auth (to access private dump's comment likers)
router.get("/comments/:commentId/likers", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dumpId = getCommentDumpId(ctx.params.commentId);
getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getCommentLikers(
ctx.params.commentId,
page,
limit,
);
ctx.response.body = {
success: true,
data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
total,
hasMore: page * limit < total,
},
};
});
// DELETE /api/comments/:commentId — auth required // DELETE /api/comments/:commentId — auth required
router.delete("/comments/:commentId", authMiddleware, (ctx) => { router.delete("/comments/:commentId", authMiddleware, (ctx) => {
const userId = ctx.state.user.userId; const userId = ctx.state.user.userId;

View File

@@ -23,6 +23,7 @@ import {
replaceFileDump, replaceFileDump,
updateDump, updateDump,
} from "../services/dump-service.ts"; } from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts";
const router = new Router({ prefix: "/api/dumps" }); const router = new Router({ prefix: "/api/dumps" });
@@ -84,6 +85,21 @@ router.get("/:dumpId", async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/:dumpId/voters", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getDumpVoters(dump.id, page, limit);
ctx.response.body = {
success: true,
data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
total,
hasMore: page * limit < total,
},
};
});
router.get("/", async (ctx) => { router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);

View File

@@ -2,6 +2,7 @@ import { Router } from "@oak/oak";
import { ALLOWED_ORIGINS } from "../config.ts"; import { ALLOWED_ORIGINS } from "../config.ts";
import { verifyJWT } from "../lib/jwt.ts"; import { verifyJWT } from "../lib/jwt.ts";
import { import {
broadcastCommentLikeUpdate,
broadcastPresence, broadcastPresence,
broadcastVoteUpdate, broadcastVoteUpdate,
getOnlineUsers, getOnlineUsers,
@@ -15,6 +16,11 @@ import {
getUserVotes, getUserVotes,
removeVote, removeVote,
} from "../services/vote-service.ts"; } from "../services/vote-service.ts";
import {
castCommentLike,
getUserCommentLikes,
removeCommentLike,
} from "../services/comment-like-service.ts";
import { getUnreadCount } from "../services/notification-service.ts"; import { getUnreadCount } from "../services/notification-service.ts";
import { getUserById } from "../services/user-service.ts"; import { getUserById } from "../services/user-service.ts";
import { import {
@@ -63,6 +69,9 @@ router.get("/ws", async (ctx) => {
try { try {
const myVotes = authPayload ? getUserVotes(authPayload.userId) : []; const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
const myCommentLikes = authPayload
? getUserCommentLikes(authPayload.userId)
: [];
const unreadNotificationCount = authPayload const unreadNotificationCount = authPayload
? getUnreadCount(authPayload.userId) ? getUnreadCount(authPayload.userId)
: 0; : 0;
@@ -70,6 +79,7 @@ router.get("/ws", async (ctx) => {
type: "welcome", type: "welcome",
users: getOnlineUsers(), users: getOnlineUsers(),
myVotes, myVotes,
myCommentLikes,
unreadNotificationCount, unreadNotificationCount,
})); }));
} catch (err) { } catch (err) {
@@ -98,6 +108,12 @@ router.get("/ws", async (ctx) => {
case "vote_remove": case "vote_remove":
handleVote(client, msg.dumpId, "remove"); handleVote(client, msg.dumpId, "remove");
break; break;
case "comment_like_cast":
handleCommentLike(client, msg.commentId, "cast");
break;
case "comment_like_remove":
handleCommentLike(client, msg.commentId, "remove");
break;
} }
}); });
@@ -141,4 +157,37 @@ function handleVote(
} }
} }
function handleCommentLike(
client: WsClient,
commentId: string,
action: "cast" | "remove",
): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
const newCount = action === "cast"
? castCommentLike(commentId, client.userId)
: removeCommentLike(commentId, client.userId);
socket.send(JSON.stringify({
type: "comment_like_ack",
commentId,
action,
likeCount: newCount,
}));
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action);
} catch (err) {
const message = err instanceof APIException ? err.message : "Like failed";
socket.send(JSON.stringify({ type: "error", message }));
}
}
export default router; export default router;

View File

@@ -0,0 +1,107 @@
import {
APIErrorCode,
APIException,
type User,
} from "../model/interfaces.ts";
import { db, isUserRow, userRowToApi } from "../model/db.ts";
import { notifyCommentOwnerLike } from "./notification-service.ts";
export function castCommentLike(commentId: string, userId: string): number {
let likeCount: number;
try {
db.exec("BEGIN;");
db.prepare(
`INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(commentId, userId, new Date().toISOString());
db.prepare(
`UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`,
).run(commentId);
const row = db.prepare(
`SELECT like_count FROM comments WHERE id = ?;`,
).get(commentId) as { like_count: number } | undefined;
db.exec("COMMIT;");
likeCount = row?.like_count ?? 0;
} catch (err) {
try {
db.exec("ROLLBACK;");
} catch { /* ignore if no active transaction */ }
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Already liked",
);
}
throw err;
}
// Notification is best-effort — must not prevent like_ack from being sent
try {
notifyCommentOwnerLike(userId, commentId);
} catch { /* ignore */ }
return likeCount;
}
export function removeCommentLike(commentId: string, userId: string): number {
try {
db.exec("BEGIN;");
const result = db.prepare(
`DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?;`,
).run(commentId, userId);
if (result.changes === 0) {
db.exec("ROLLBACK;");
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Like not found");
}
db.prepare(
`UPDATE comments SET like_count = like_count - 1 WHERE id = ?;`,
).run(commentId);
const row = db.prepare(
`SELECT like_count FROM comments WHERE id = ?;`,
).get(commentId) as { like_count: number } | undefined;
db.exec("COMMIT;");
return row?.like_count ?? 0;
} catch (err) {
if (err instanceof APIException) throw err;
db.exec("ROLLBACK;");
throw err;
}
}
export function getUserCommentLikes(userId: string): string[] {
const rows = db.prepare(
`SELECT comment_id FROM comment_likes WHERE user_id = ?;`,
).all(userId) as { comment_id: string }[];
return rows.map((r) => r.comment_id);
}
export function getCommentLikers(
commentId: string,
page: number,
limit: number,
): { items: User[]; total: number } {
const offset = (page - 1) * limit;
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM comment_likes WHERE comment_id = ?;`,
).get(commentId) as { count: number } | undefined;
const rawRows = db.prepare(
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at,
u.avatar_mime, u.description, u.invited_by, u.email,
i.username as invited_by_username
FROM users u
LEFT JOIN users i ON i.id = u.invited_by
INNER JOIN comment_likes cl ON cl.comment_id = ? AND cl.user_id = u.id
ORDER BY cl.created_at DESC
LIMIT ? OFFSET ?;`,
).all(commentId, limit, offset);
if (!rawRows.every(isUserRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed user data",
);
}
return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 };
}

View File

@@ -12,7 +12,7 @@ import {
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
const SELECT_COLS = const SELECT_COLS =
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, `c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count,
u.username as author_username, u.avatar_mime as author_avatar_mime`; u.username as author_username, u.avatar_mime as author_avatar_mime`;
function fetchComment(commentId: string): Comment { function fetchComment(commentId: string): Comment {
@@ -32,6 +32,16 @@ function fetchComment(commentId: string): Comment {
return commentRowToApi(row); return commentRowToApi(row);
} }
export function getCommentDumpId(commentId: string): string {
const row = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`).get(
commentId,
) as { dump_id: string } | undefined;
if (!row) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
}
return row.dump_id;
}
export function getComments(dumpId: string): Comment[] { export function getComments(dumpId: string): Comment[] {
const rows = db.prepare( const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id

View File

@@ -306,6 +306,38 @@ export function notifyDumpOwnerNewComment(
); );
} }
export function notifyCommentOwnerLike(
likerId: string,
commentId: string,
): void {
const likerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(likerId) as { username: string } | undefined;
const commentRow = db.prepare(
`SELECT c.user_id, c.dump_id, d.title as dump_title
FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
).get(commentId) as
| { user_id: string; dump_id: string; dump_title: string }
| undefined;
if (!likerRow || !commentRow) return;
if (likerId === commentRow.user_id) return; // no self-notification
createNotification(
commentRow.user_id,
"comment_liked",
{
likerId,
likerUsername: likerRow.username,
commentId,
dumpId: commentRow.dump_id,
dumpTitle: commentRow.dump_title,
},
`like:${commentId}:${likerId}`,
);
}
export function notifyPlaylistFollowersNewDump( export function notifyPlaylistFollowersNewDump(
playlistId: string, playlistId: string,
playlistTitle: string, playlistTitle: string,

View File

@@ -1,5 +1,9 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import {
import { db } from "../model/db.ts"; APIErrorCode,
APIException,
type User,
} from "../model/interfaces.ts";
import { db, isUserRow, userRowToApi } from "../model/db.ts";
import { notifyDumpOwnerUpvote } from "./notification-service.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts";
export function castVote(dumpId: string, userId: string): number { export function castVote(dumpId: string, userId: string): number {
@@ -68,3 +72,36 @@ export function getUserVotes(userId: string): string[] {
).all(userId) as { dump_id: string }[]; ).all(userId) as { dump_id: string }[];
return rows.map((r) => r.dump_id); return rows.map((r) => r.dump_id);
} }
export function getDumpVoters(
dumpId: string,
page: number,
limit: number,
): { items: User[]; total: number } {
const offset = (page - 1) * limit;
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM votes WHERE dump_id = ?;`,
).get(dumpId) as { count: number } | undefined;
const rawRows = db.prepare(
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at,
u.avatar_mime, u.description, u.invited_by, u.email,
i.username as invited_by_username
FROM users u
LEFT JOIN users i ON i.id = u.invited_by
INNER JOIN votes v ON v.dump_id = ? AND v.user_id = u.id
ORDER BY v.created_at DESC
LIMIT ? OFFSET ?;`,
).all(dumpId, limit, offset);
if (!rawRows.every(isUserRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed user data",
);
}
return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 };
}

View File

@@ -117,6 +117,23 @@ export function broadcastVoteUpdate(
} }
} }
export function broadcastCommentLikeUpdate(
commentId: string,
likeCount: number,
likerId: string,
action: "cast" | "remove",
): void {
for (const client of clients) {
send(client.socket, {
type: "comment_likes_update",
commentId,
likeCount,
likerId,
action,
});
}
}
function sendToPlaylistAudience( function sendToPlaylistAudience(
playlist: Pick<Playlist, "isPublic" | "userId">, playlist: Pick<Playlist, "isPublic" | "userId">,
data: ServerToClientMessage, data: ServerToClientMessage,

43
api/sql/migrate.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { DatabaseSync } from "node:sqlite";
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
interface Migration {
name: string;
up: (db: DatabaseSync) => void;
}
// Append-only — never edit or reorder a migration once it has shipped.
// Each `up` must be idempotent: it also runs against fresh databases created
// straight from schema.sql, where the change it makes already exists.
const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes },
];
export function runMigrations(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);`);
const applied = new Set(
(db.prepare(`SELECT name FROM schema_migrations;`).all() as {
name: string;
}[]).map((r) => r.name),
);
for (const { name, up } of MIGRATIONS) {
if (applied.has(name)) continue;
db.exec("BEGIN;");
try {
up(db);
db.prepare(
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?);`,
).run(name, new Date().toISOString());
db.exec("COMMIT;");
console.log(`[migrate] applied ${name}`);
} catch (err) {
db.exec("ROLLBACK;");
throw err;
}
}
}

View File

@@ -0,0 +1,27 @@
import type { DatabaseSync } from "node:sqlite";
// Idempotent: safe to run against a fresh db (already created from schema.sql
// with these columns/tables) or an existing one that predates them.
export function up(db: DatabaseSync): void {
const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as {
name: string;
}[];
if (!commentCols.some((c) => c.name === "like_count")) {
db.exec(
`ALTER TABLE comments ADD COLUMN like_count INTEGER NOT NULL DEFAULT 0;`,
);
}
db.exec(`CREATE TABLE IF NOT EXISTS comment_likes (
comment_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (comment_id, user_id),
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_comment_likes_user ON comment_likes(user_id);`,
);
}

View File

@@ -71,17 +71,28 @@ CREATE TABLE comments (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT, updated_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0, deleted INTEGER NOT NULL DEFAULT 0,
like_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE, FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
); );
CREATE TABLE comment_likes (
comment_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (comment_id, user_id),
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_dumps_user ON dumps(user_id); CREATE INDEX idx_dumps_user ON dumps(user_id);
CREATE INDEX idx_votes_user ON votes(user_id); CREATE INDEX idx_votes_user ON votes(user_id);
CREATE INDEX idx_playlists_user ON playlists(user_id); CREATE INDEX idx_playlists_user ON playlists(user_id);
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position); CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id); CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at); CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
CREATE TABLE follows ( CREATE TABLE follows (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

View File

@@ -26,9 +26,10 @@
"imports": { "imports": {
"@db/sqlite": "jsr:@db/sqlite@^0.13.0", "@db/sqlite": "jsr:@db/sqlite@^0.13.0",
"@oak/oak": "jsr:@oak/oak@^17.2.0", "@oak/oak": "jsr:@oak/oak@^17.2.0",
"@panva/jose": "jsr:@panva/jose@^6.2.1", "@panva/jose": "jsr:@panva/jose@^6.2.3",
"@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1", "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1",
"nodemailer": "npm:nodemailer@^8.0.4", "nodemailer": "npm:nodemailer@^9.0.1",
"marked": "npm:marked@^15.0.0" "marked": "npm:marked@^18.0.5"
} },
"allowScripts": ["npm:@swc/core@1.15.41", "npm:esbuild@0.25.12"]
} }

1161
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,33 +10,33 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lingui/cli": "6.0.0-next.3", "@lingui/cli": "6.4.0",
"@lingui/conf": "6.0.0-next.3", "@lingui/conf": "6.4.0",
"@lingui/core": "6.0.0-next.3", "@lingui/core": "6.4.0",
"@lingui/format-po": "6.0.0-next.3", "@lingui/format-po": "6.4.0",
"@lingui/react": "6.0.0-next.3", "@lingui/react": "6.4.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.17",
"@lingui/swc-plugin": "6.0.0-next.2", "@lingui/swc-plugin": "6.4.0",
"@vitejs/plugin-react-swc": "^4.3.0", "@vitejs/plugin-react-swc": "^4.3.1",
"frimousse": "^0.3.0", "frimousse": "^0.3.0",
"jiti": "^2.6.1", "jiti": "^2.7.0",
"react": "^19.2.4", "react": "^19.2.7",
"react-dom": "^19.2.4", "react-dom": "^19.2.7",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.13.1", "react-router": "^8.0.1",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@lingui/vite-plugin": "6.0.0-next.3", "@lingui/vite-plugin": "6.4.0",
"@eslint/js": "^9.39.4", "@eslint/js": "^10.0.1",
"@types/node": "^24.12.0", "@types/node": "^26.0.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^9.39.4", "eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.4.0", "globals": "^17.6.0",
"typescript": "~5.9.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.61.1",
"vite": "^8.0.0" "vite": "^8.0.16"
} }
} }

View File

@@ -1272,6 +1272,19 @@ body.has-player .fab-new {
cursor: default; cursor: default;
} }
.vote-count-clickable {
display: inline-block;
cursor: pointer;
/* Padding+matching negative margin enlarges the click target without
shifting layout — the box grows but its visual position is unchanged. */
padding: 0.4rem;
margin: -0.4rem;
}
.vote-count-clickable:hover {
text-decoration: underline;
}
/* ── Dump OP line ── */ /* ── Dump OP line ── */
.dump-op { .dump-op {
display: flex; display: flex;
@@ -2176,6 +2189,63 @@ body.has-player .fab-new {
background: var(--color-header-user-bg-hover); background: var(--color-header-user-bg-hover);
} }
.user-list-popover {
/* position/top/left set inline (fixed, anchored to the trigger's viewport rect) */
min-width: 180px;
max-height: 280px;
overflow-y: auto;
background: var(--color-surface);
border: 2px solid var(--color-border-subtle);
border-radius: 10px;
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
z-index: 100;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.user-list-popover-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 7px;
text-decoration: none;
color: var(--color-text);
font-size: 0.85rem;
font-weight: 600;
transition: background 0.12s;
}
.user-list-popover-item:hover {
background: var(--color-header-user-bg-hover);
}
.user-list-popover-status {
margin: 0;
padding: 0.5rem 0.6rem;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.user-list-popover-load-more {
background: none;
border: none;
cursor: pointer;
padding: 0.4rem 0.6rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-accent);
text-align: left;
font-family: inherit;
}
.user-list-popover-load-more:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Auth card ── */ /* ── Auth card ── */
.auth-card { .auth-card {
width: 100%; width: 100%;
@@ -3560,6 +3630,31 @@ body.has-player .fab-new {
background: color-mix(in srgb, var(--color-danger) 10%, transparent); background: color-mix(in srgb, var(--color-danger) 10%, transparent);
} }
.comment-action-btn--liked {
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
}
.comment-action-btn--liked:hover {
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
}
.like-count-clickable {
display: inline-block;
cursor: pointer;
/* Padding+matching negative margin enlarges the click target without
shifting layout — the box grows but its visual position is unchanged. */
padding: 0.3rem;
margin: -0.3rem;
}
.like-count-clickable:hover {
text-decoration: underline;
}
.comment-replies { .comment-replies {
padding-left: max(0.4rem, calc(1.25rem - var(--depth, 0) * 0.1rem)); padding-left: max(0.4rem, calc(1.25rem - var(--depth, 0) * 0.1rem));
margin-left: max(0.25rem, calc(1.1rem - var(--depth, 0) * 0.09rem)); margin-left: max(0.25rem, calc(1.1rem - var(--depth, 0) * 0.09rem));

View File

@@ -14,10 +14,12 @@ import { deserializeComment, parseAPIResponse } from "../model.ts";
import { Avatar } from "./Avatar.tsx"; import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx"; import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
import { LikeButton } from "./LikeButton.tsx";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { ErrorCard } from "./ErrorCard.tsx"; import { ErrorCard } from "./ErrorCard.tsx";
import { Tooltip } from "./Tooltip.tsx"; import { Tooltip } from "./Tooltip.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx"; import { ConfirmModal } from "./ConfirmModal.tsx";
import { useWS } from "../hooks/useWS.ts";
interface CommentThreadProps { interface CommentThreadProps {
dumpId: string; dumpId: string;
@@ -75,6 +77,9 @@ function CommentNode({
const replyEditorRef = useRef<TextEditorHandle>(null); const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null); const editEditorRef = useRef<TextEditorHandle>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } =
useWS();
const children = tree.get(comment.id) ?? []; const children = tree.get(comment.id) ?? [];
async function handleReply(e?: React.SubmitEvent) { async function handleReply(e?: React.SubmitEvent) {
@@ -285,6 +290,14 @@ function CommentNode({
) )
: <Markdown className="comment-body">{comment.body}</Markdown>} : <Markdown className="comment-body">{comment.body}</Markdown>}
<div className="comment-actions"> <div className="comment-actions">
<LikeButton
commentId={comment.id}
count={likeCounts[comment.id] ?? comment.likeCount}
liked={myLikes.has(comment.id)}
disabled={!currentUser}
onLike={castCommentLike}
onUnlike={removeCommentLike}
/>
{currentUser && !editOpen && ( {currentUser && !editOpen && (
<button <button
type="button" type="button"

View File

@@ -0,0 +1,57 @@
import { useRef, useState } from "react";
import { API_URL } from "../config/api.ts";
import { UserListPopover } from "./UserListPopover.tsx";
interface LikeButtonProps {
commentId: string;
count: number;
liked: boolean;
disabled?: boolean;
onLike: (commentId: string) => void;
onUnlike: (commentId: string) => void;
}
export function LikeButton(
{ commentId, count, liked, disabled, onLike, onUnlike }: LikeButtonProps,
) {
const [popoverOpen, setPopoverOpen] = useState(false);
const countRef = useRef<HTMLSpanElement>(null);
return (
<>
<button
type="button"
className={`comment-action-btn${
liked ? " comment-action-btn--liked" : ""
}`}
onClick={() => liked ? onUnlike(commentId) : onLike(commentId)}
disabled={disabled}
aria-label={liked ? "Remove like" : "Like"}
title={disabled ? "Log in to like" : undefined}
>
{" "}
{count > 0
? (
<span
ref={countRef}
className="like-count-clickable"
onClick={(e) => {
e.stopPropagation();
setPopoverOpen((o) => !o);
}}
>
{count}
</span>
)
: count}
</button>
{popoverOpen && (
<UserListPopover
anchorRef={countRef}
onClose={() => setPopoverOpen(false)}
fetchUrl={`${API_URL}/api/comments/${commentId}/likers`}
/>
)}
</>
);
}

View File

@@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Avatar } from "./Avatar.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { DEFAULT_PAGE_SIZE } from "../config/api.ts";
import {
deserializePublicUser,
type PaginatedData,
parseAPIResponse,
type PublicUser,
type RawPublicUser,
} from "../model.ts";
interface UserListPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
fetchUrl: string;
}
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
users: PublicUser[];
page: number;
hasMore: boolean;
loadingMore: boolean;
};
export function UserListPopover(
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps,
) {
const { token } = useAuth();
const [state, setState] = useState<State>({ status: "loading" });
const [rect, setRect] = useState<DOMRect | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// Position via the anchor's viewport rect (mirrors Tooltip.tsx) — fixed +
// portaled to <body> so the popover can't be clipped by a card's overflow.
useEffect(() => {
setRect(anchorRef.current?.getBoundingClientRect() ?? null);
}, [anchorRef]);
useEffect(() => {
function onMouseDown(e: MouseEvent) {
const target = e.target as Node;
if (
popoverRef.current?.contains(target) ||
anchorRef.current?.contains(target)
) {
return;
}
onClose();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
function onScroll() {
onClose();
}
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("scroll", onScroll, true);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("scroll", onScroll, true);
};
}, [onClose, anchorRef]);
useEffect(() => {
const controller = new AbortController();
fetch(`${fetchUrl}?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const apiResponse = parseAPIResponse<PaginatedData<RawPublicUser>>(
body,
);
if (!apiResponse.success) {
setState({ status: "error", error: apiResponse.error.message });
return;
}
setState({
status: "loaded",
users: apiResponse.data.items.map(deserializePublicUser),
page: 1,
hasMore: apiResponse.data.hasMore,
loadingMore: false,
});
})
.catch(() => {
if (controller.signal.aborted) return;
setState({ status: "error", error: t`Could not load.` });
});
return () => controller.abort();
}, [fetchUrl, token]);
const handleLoadMore = () => {
if (state.status !== "loaded" || state.loadingMore) return;
const nextPage = state.page + 1;
setState({ ...state, loadingMore: true });
fetch(`${fetchUrl}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const apiResponse = parseAPIResponse<PaginatedData<RawPublicUser>>(
body,
);
setState((prev) => {
if (prev.status !== "loaded") return prev;
if (!apiResponse.success) return { ...prev, loadingMore: false };
return {
status: "loaded",
users: [
...prev.users,
...apiResponse.data.items.map(deserializePublicUser),
],
page: nextPage,
hasMore: apiResponse.data.hasMore,
loadingMore: false,
};
});
});
};
if (!rect) return null;
return createPortal(
<div
className="user-list-popover"
role="menu"
ref={popoverRef}
style={{
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
}}
>
{state.status === "loading" && (
<p className="user-list-popover-status">
<Trans>Loading</Trans>
</p>
)}
{state.status === "error" && (
<p className="user-list-popover-status">{state.error}</p>
)}
{state.status === "loaded" && state.users.length === 0 && (
<p className="user-list-popover-status">
<Trans>No one yet.</Trans>
</p>
)}
{state.status === "loaded" && state.users.length > 0 && (
<>
{state.users.map((u) => (
<Link
key={u.id}
to={`/users/${u.username}`}
className="user-list-popover-item"
role="menuitem"
onClick={onClose}
>
<Avatar
userId={u.id}
username={u.username}
hasAvatar={!!u.avatarMime}
size={24}
/>
<span>{u.username}</span>
</Link>
))}
{state.hasMore && (
<button
type="button"
className="user-list-popover-load-more"
onClick={handleLoadMore}
disabled={state.loadingMore}
>
{state.loadingMore
? <Trans>Loading</Trans>
: <Trans>Load more</Trans>}
</button>
)}
</>
)}
</div>,
document.body,
);
}

View File

@@ -1,3 +1,7 @@
import { useRef, useState } from "react";
import { API_URL } from "../config/api.ts";
import { UserListPopover } from "./UserListPopover.tsx";
interface VoteButtonProps { interface VoteButtonProps {
dumpId: string; dumpId: string;
count: number; count: number;
@@ -10,16 +14,42 @@ interface VoteButtonProps {
export function VoteButton( export function VoteButton(
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps, { dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
) { ) {
const [popoverOpen, setPopoverOpen] = useState(false);
const countRef = useRef<HTMLSpanElement>(null);
return ( return (
<button <>
type="button" <button
className={`vote-btn${voted ? " vote-btn--active" : ""}`} type="button"
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)} className={`vote-btn${voted ? " vote-btn--active" : ""}`}
disabled={disabled} onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
aria-label={voted ? "Remove vote" : "Upvote"} disabled={disabled}
title={disabled ? "Log in to vote" : undefined} aria-label={voted ? "Remove vote" : "Upvote"}
> title={disabled ? "Log in to vote" : undefined}
{count} >
</button> {" "}
{count > 0
? (
<span
ref={countRef}
className="vote-count-clickable"
onClick={(e) => {
e.stopPropagation();
setPopoverOpen((o) => !o);
}}
>
{count}
</span>
)
: count}
</button>
{popoverOpen && (
<UserListPopover
anchorRef={countRef}
onClose={() => setPopoverOpen(false)}
fetchUrl={`${API_URL}/api/dumps/${dumpId}/voters`}
/>
)}
</>
); );
} }

View File

@@ -14,6 +14,12 @@ export interface VoteEvent {
action: "cast" | "remove"; action: "cast" | "remove";
} }
export interface LikeEvent {
commentId: string;
likerId: string;
action: "cast" | "remove";
}
export interface PlaylistEvent { export interface PlaylistEvent {
type: "created" | "updated" | "deleted" | "dumps_updated"; type: "created" | "updated" | "deleted" | "dumps_updated";
playlistId: string; playlistId: string;
@@ -39,9 +45,12 @@ export interface WSContextValue {
onlineUsers: OnlineUser[]; onlineUsers: OnlineUser[];
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
likeCounts: Record<string, number>;
myLikes: Set<string>;
recentDumps: Dump[]; recentDumps: Dump[];
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
lastVoteEvent: VoteEvent | null; lastVoteEvent: VoteEvent | null;
lastLikeEvent: LikeEvent | null;
lastDumpEvent: Dump | null; lastDumpEvent: Dump | null;
lastPlaylistEvent: PlaylistEvent | null; lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>; deletedPlaylistIds: Set<string>;
@@ -51,6 +60,8 @@ export interface WSContextValue {
lastNotification: Notification | null; lastNotification: Notification | null;
castVote: (dumpId: string) => void; castVote: (dumpId: string) => void;
removeVote: (dumpId: string) => void; removeVote: (dumpId: string) => void;
castCommentLike: (commentId: string) => void;
removeCommentLike: (commentId: string) => void;
injectDump: (dump: Dump) => void; injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void; clearUnreadNotifications: () => void;
} }
@@ -61,9 +72,12 @@ export const WSContext = createContext<WSContextValue>({
onlineUsers: [], onlineUsers: [],
voteCounts: {}, voteCounts: {},
myVotes: new Set(), myVotes: new Set(),
likeCounts: {},
myLikes: new Set(),
recentDumps: [], recentDumps: [],
deletedDumpIds: new Set(), deletedDumpIds: new Set(),
lastVoteEvent: null, lastVoteEvent: null,
lastLikeEvent: null,
lastDumpEvent: null, lastDumpEvent: null,
lastPlaylistEvent: null, lastPlaylistEvent: null,
deletedPlaylistIds: new Set(), deletedPlaylistIds: new Set(),
@@ -73,6 +87,8 @@ export const WSContext = createContext<WSContextValue>({
lastNotification: null, lastNotification: null,
castVote: () => {}, castVote: () => {},
removeVote: () => {}, removeVote: () => {},
castCommentLike: () => {},
removeCommentLike: () => {},
injectDump: () => {}, injectDump: () => {},
clearUnreadNotifications: () => {}, clearUnreadNotifications: () => {},
}); });

View File

@@ -9,6 +9,7 @@ import {
} from "react"; } from "react";
import { import {
type CommentEvent, type CommentEvent,
type LikeEvent,
type PlaylistEvent, type PlaylistEvent,
type UserEvent, type UserEvent,
type VoteEvent, type VoteEvent,
@@ -46,6 +47,11 @@ interface PendingVote {
rollback: () => void; rollback: () => void;
} }
interface PendingLike {
timeout: ReturnType<typeof setTimeout>;
rollback: () => void;
}
// Minimal runtime check: verify the `type` field is a known string so we can // Minimal runtime check: verify the `type` field is a known string so we can
// safely cast to the discriminated union and let TypeScript narrow from there. // safely cast to the discriminated union and let TypeScript narrow from there.
function parseWSMessage(data: string): IncomingWSMessage | null { function parseWSMessage(data: string): IncomingWSMessage | null {
@@ -80,9 +86,12 @@ export function WSProvider({ children }: WSProviderProps) {
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]); const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({}); const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
const [myVotes, setMyVotes] = useState<Set<string>>(new Set()); const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
const [likeCounts, setLikeCounts] = useState<Record<string, number>>({});
const [myLikes, setMyLikes] = useState<Set<string>>(new Set());
const [recentDumps, setRecentDumps] = useState<Dump[]>([]); const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set()); const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null); const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
const [lastLikeEvent, setLastLikeEvent] = useState<LikeEvent | null>(null);
const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null); const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
const [lastPlaylistEvent, setLastPlaylistEvent] = useState< const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
PlaylistEvent | null PlaylistEvent | null
@@ -102,6 +111,8 @@ export function WSProvider({ children }: WSProviderProps) {
// Refs to avoid stale closures in event handlers // Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts); const voteCountsRef = useRef(voteCounts);
const myVotesRef = useRef(myVotes); const myVotesRef = useRef(myVotes);
const likeCountsRef = useRef(likeCounts);
const myLikesRef = useRef(myLikes);
const userIdRef = useRef(userId); const userIdRef = useRef(userId);
// Stable ref for logout so the effect doesn't reconnect when the function // Stable ref for logout so the effect doesn't reconnect when the function
// reference changes on re-renders. // reference changes on re-renders.
@@ -109,6 +120,8 @@ export function WSProvider({ children }: WSProviderProps) {
useLayoutEffect(() => { useLayoutEffect(() => {
voteCountsRef.current = voteCounts; voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes; myVotesRef.current = myVotes;
likeCountsRef.current = likeCounts;
myLikesRef.current = myLikes;
userIdRef.current = userId; userIdRef.current = userId;
onForceLogoutRef.current = logout; onForceLogoutRef.current = logout;
}); });
@@ -118,6 +131,10 @@ export function WSProvider({ children }: WSProviderProps) {
const pendingRef = useRef<Map<string, PendingVote>>( const pendingRef = useRef<Map<string, PendingVote>>(
new Map(), new Map(),
); );
// Tracks pending optimistic comment likes: commentId → pending rollback handler
const pendingLikesRef = useRef<Map<string, PendingLike>>(
new Map(),
);
const clearPendingVote = useCallback((dumpId: string) => { const clearPendingVote = useCallback((dumpId: string) => {
const pending = pendingRef.current.get(dumpId); const pending = pendingRef.current.get(dumpId);
@@ -145,6 +162,32 @@ export function WSProvider({ children }: WSProviderProps) {
pendingRef.current.set(dumpId, { timeout, rollback }); pendingRef.current.set(dumpId, { timeout, rollback });
}, [clearPendingVote]); }, [clearPendingVote]);
const clearPendingLike = useCallback((commentId: string) => {
const pending = pendingLikesRef.current.get(commentId);
if (!pending) return;
clearTimeout(pending.timeout);
pendingLikesRef.current.delete(commentId);
}, []);
const clearAllPendingLikes = useCallback(() => {
for (const pending of pendingLikesRef.current.values()) {
clearTimeout(pending.timeout);
}
pendingLikesRef.current.clear();
}, []);
const schedulePendingLike = useCallback((
commentId: string,
rollback: () => void,
) => {
clearPendingLike(commentId);
const timeout = setTimeout(() => {
pendingLikesRef.current.delete(commentId);
rollback();
}, ACK_TIMEOUT);
pendingLikesRef.current.set(commentId, { timeout, rollback });
}, [clearPendingLike]);
useEffect(() => { useEffect(() => {
let closed = false; let closed = false;
let backoff = 500; let backoff = 500;
@@ -195,10 +238,12 @@ export function WSProvider({ children }: WSProviderProps) {
backoff = 500; // reset backoff on successful connect backoff = 500; // reset backoff on successful connect
setOnlineUsers(msg.users); setOnlineUsers(msg.users);
setMyVotes(new Set(msg.myVotes)); setMyVotes(new Set(msg.myVotes));
setMyLikes(new Set(msg.myCommentLikes));
setUnreadNotificationCount(msg.unreadNotificationCount); setUnreadNotificationCount(msg.unreadNotificationCount);
// welcome provides authoritative server state — cancel any // welcome provides authoritative server state — cancel any
// in-flight revert timers, they are now superseded. // in-flight revert timers, they are now superseded.
clearAllPendingVotes(); clearAllPendingVotes();
clearAllPendingLikes();
break; break;
case "presence_update": case "presence_update":
@@ -223,6 +268,24 @@ export function WSProvider({ children }: WSProviderProps) {
break; break;
} }
case "comment_likes_update": {
const { commentId, likeCount, likerId, action } = msg;
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
setLastLikeEvent({ commentId, likerId, action });
// Keep myLikes in sync across tabs: if this like event belongs to
// the current user (from another tab), update myLikes accordingly.
if (likerId === userIdRef.current) {
clearPendingLike(commentId);
setMyLikes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(commentId);
else next.delete(commentId);
return next;
});
}
break;
}
case "dump_created": { case "dump_created": {
const dump = deserializeDump(msg.dump); const dump = deserializeDump(msg.dump);
setRecentDumps((prev) => [dump, ...prev]); setRecentDumps((prev) => [dump, ...prev]);
@@ -269,6 +332,21 @@ export function WSProvider({ children }: WSProviderProps) {
break; break;
} }
case "comment_like_ack": {
const { commentId, action, likeCount } = msg;
clearPendingLike(commentId);
// Reconcile with authoritative count
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
// Confirm like state
setMyLikes((prev) => {
const next = new Set(prev);
if (action === "cast") next.add(commentId);
else next.delete(commentId);
return next;
});
break;
}
case "playlist_created": case "playlist_created":
case "playlist_updated": { case "playlist_updated": {
const playlist = deserializePlaylist(msg.playlist); const playlist = deserializePlaylist(msg.playlist);
@@ -373,6 +451,7 @@ export function WSProvider({ children }: WSProviderProps) {
connect(); connect();
const pending = pendingRef.current; const pending = pendingRef.current;
const pendingLikes = pendingLikesRef.current;
return () => { return () => {
closed = true; closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
@@ -383,8 +462,18 @@ export function WSProvider({ children }: WSProviderProps) {
clearTimeout(pendingVote.timeout); clearTimeout(pendingVote.timeout);
} }
pending.clear(); pending.clear();
for (const pendingLike of pendingLikes.values()) {
clearTimeout(pendingLike.timeout);
}
pendingLikes.clear();
}; };
}, [clearAllPendingVotes, clearPendingVote, token]); }, [
clearAllPendingVotes,
clearPendingVote,
clearAllPendingLikes,
clearPendingLike,
token,
]);
const castVote = useCallback((dumpId: string) => { const castVote = useCallback((dumpId: string) => {
// Optimistic update // Optimistic update
@@ -455,6 +544,78 @@ export function WSProvider({ children }: WSProviderProps) {
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingVote]); }, [schedulePendingVote]);
const castCommentLike = useCallback((commentId: string) => {
// Optimistic update
const prevCount = likeCountsRef.current[commentId] ?? 0;
const prevLiked = myLikesRef.current.has(commentId);
if (prevLiked) return; // already liked
setMyLikes((prev) => {
const n = new Set(prev);
n.add(commentId);
return n;
});
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
// Schedule revert if no authoritative confirmation arrives.
schedulePendingLike(commentId, () => {
setMyLikes((prev) => {
const n = new Set(prev);
n.delete(commentId);
return n;
});
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
});
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "comment_like_cast", commentId } satisfies OutgoingWSMessage,
),
);
}
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingLike]);
const removeCommentLike = useCallback((commentId: string) => {
// Optimistic update
const prevCount = likeCountsRef.current[commentId] ?? 0;
const prevLiked = myLikesRef.current.has(commentId);
if (!prevLiked) return; // not liked
setMyLikes((prev) => {
const n = new Set(prev);
n.delete(commentId);
return n;
});
setLikeCounts((prev) => ({
...prev,
[commentId]: Math.max(0, prevCount - 1),
}));
// Schedule revert if no authoritative confirmation arrives.
schedulePendingLike(commentId, () => {
setMyLikes((prev) => {
const n = new Set(prev);
n.add(commentId);
return n;
});
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
});
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{
type: "comment_like_remove",
commentId,
} satisfies OutgoingWSMessage,
),
);
}
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingLike]);
const injectDump = useCallback((dump: Dump) => { const injectDump = useCallback((dump: Dump) => {
setRecentDumps((prev) => { setRecentDumps((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev; if (prev.some((d) => d.id === dump.id)) return prev;
@@ -472,9 +633,12 @@ export function WSProvider({ children }: WSProviderProps) {
onlineUsers, onlineUsers,
voteCounts, voteCounts,
myVotes, myVotes,
likeCounts,
myLikes,
recentDumps, recentDumps,
deletedDumpIds, deletedDumpIds,
lastVoteEvent, lastVoteEvent,
lastLikeEvent,
lastDumpEvent, lastDumpEvent,
lastPlaylistEvent, lastPlaylistEvent,
deletedPlaylistIds, deletedPlaylistIds,
@@ -484,6 +648,8 @@ export function WSProvider({ children }: WSProviderProps) {
lastNotification, lastNotification,
castVote, castVote,
removeVote, removeVote,
castCommentLike,
removeCommentLike,
injectDump, injectDump,
clearUnreadNotifications, clearUnreadNotifications,
}), [ }), [
@@ -492,9 +658,12 @@ export function WSProvider({ children }: WSProviderProps) {
onlineUsers, onlineUsers,
voteCounts, voteCounts,
myVotes, myVotes,
likeCounts,
myLikes,
recentDumps, recentDumps,
deletedDumpIds, deletedDumpIds,
lastVoteEvent, lastVoteEvent,
lastLikeEvent,
lastDumpEvent, lastDumpEvent,
lastPlaylistEvent, lastPlaylistEvent,
deletedPlaylistIds, deletedPlaylistIds,
@@ -504,6 +673,8 @@ export function WSProvider({ children }: WSProviderProps) {
lastNotification, lastNotification,
castVote, castVote,
removeVote, removeVote,
castCommentLike,
removeCommentLike,
injectDump, injectDump,
clearUnreadNotifications, clearUnreadNotifications,
]); ]);

View File

@@ -162,6 +162,7 @@ export interface Comment {
createdAt: Date; createdAt: Date;
updatedAt?: Date; updatedAt?: Date;
deleted: boolean; deleted: boolean;
likeCount: number;
authorUsername: string; authorUsername: string;
authorAvatarMime?: string; authorAvatarMime?: string;
} }
@@ -249,7 +250,8 @@ export type NotificationType =
| "playlist_dump_added" | "playlist_dump_added"
| "dump_upvoted" | "dump_upvoted"
| "user_mentioned" | "user_mentioned"
| "dump_commented"; | "dump_commented"
| "comment_liked";
export interface PlaylistFollowedData { export interface PlaylistFollowedData {
followerId: string; followerId: string;
@@ -301,6 +303,14 @@ export interface DumpCommentedData {
dumpTitle: string; dumpTitle: string;
} }
export interface CommentLikedData {
likerId: string;
likerUsername: string;
commentId: string;
dumpId: string;
dumpTitle: string;
}
export type NotificationData = export type NotificationData =
| PlaylistFollowedData | PlaylistFollowedData
| UserFollowedData | UserFollowedData
@@ -308,7 +318,8 @@ export type NotificationData =
| PlaylistDumpAddedData | PlaylistDumpAddedData
| DumpUpvotedData | DumpUpvotedData
| UserMentionedData | UserMentionedData
| DumpCommentedData; | DumpCommentedData
| CommentLikedData;
export interface Notification { export interface Notification {
id: string; id: string;
@@ -350,6 +361,7 @@ export interface WSWelcomeMessage {
type: "welcome"; type: "welcome";
users: OnlineUser[]; users: OnlineUser[];
myVotes: string[]; myVotes: string[];
myCommentLikes: string[];
unreadNotificationCount: number; unreadNotificationCount: number;
} }
export interface WSPresenceUpdateMessage { export interface WSPresenceUpdateMessage {
@@ -369,6 +381,19 @@ export interface WSVoteAckMessage {
action: "cast" | "remove"; action: "cast" | "remove";
voteCount: number; voteCount: number;
} }
export interface WSCommentLikesUpdateMessage {
type: "comment_likes_update";
commentId: string;
likeCount: number;
likerId: string;
action: "cast" | "remove";
}
export interface WSCommentLikeAckMessage {
type: "comment_like_ack";
commentId: string;
action: "cast" | "remove";
likeCount: number;
}
export interface WSDumpCreatedMessage { export interface WSDumpCreatedMessage {
type: "dump_created"; type: "dump_created";
dump: RawDump; dump: RawDump;
@@ -435,6 +460,8 @@ export type IncomingWSMessage =
| WSPresenceUpdateMessage | WSPresenceUpdateMessage
| WSVotesUpdateMessage | WSVotesUpdateMessage
| WSVoteAckMessage | WSVoteAckMessage
| WSCommentLikesUpdateMessage
| WSCommentLikeAckMessage
| WSDumpCreatedMessage | WSDumpCreatedMessage
| WSDumpUpdatedMessage | WSDumpUpdatedMessage
| WSDumpDeletedMessage | WSDumpDeletedMessage
@@ -465,11 +492,21 @@ export interface WSVoteRemoveMessage {
type: "vote_remove"; type: "vote_remove";
dumpId: string; dumpId: string;
} }
export interface WSCommentLikeCastMessage {
type: "comment_like_cast";
commentId: string;
}
export interface WSCommentLikeRemoveMessage {
type: "comment_like_remove";
commentId: string;
}
export type OutgoingWSMessage = export type OutgoingWSMessage =
| WSPongMessage | WSPongMessage
| WSVoteCastMessage | WSVoteCastMessage
| WSVoteRemoveMessage; | WSVoteRemoveMessage
| WSCommentLikeCastMessage
| WSCommentLikeRemoveMessage;
/** /**
* Follows * Follows

View File

@@ -10,6 +10,7 @@ import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx"; import { Tooltip } from "../components/Tooltip.tsx";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import type { import type {
CommentLikedData,
DumpCommentedData, DumpCommentedData,
DumpUpvotedData, DumpUpvotedData,
Notification, Notification,
@@ -43,7 +44,8 @@ type NotifIconKind =
| "dump" | "dump"
| "playlist" | "playlist"
| "mention" | "mention"
| "comment"; | "comment"
| "like";
function notifIconKind(type: Notification["type"]): NotifIconKind { function notifIconKind(type: Notification["type"]): NotifIconKind {
switch (type) { switch (type) {
@@ -61,6 +63,8 @@ function notifIconKind(type: Notification["type"]): NotifIconKind {
return "mention"; return "mention";
case "dump_commented": case "dump_commented":
return "comment"; return "comment";
case "comment_liked":
return "like";
} }
} }
@@ -76,6 +80,12 @@ const FollowSvg = () => (
</svg> </svg>
); );
const HeartSvg = () => (
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
<path d="M5 9 C2 6.5 0 5 0 2.8 C0 1.2 1.2 0 2.6 0 C3.6 0 4.5 0.6 5 1.5 C5.5 0.6 6.4 0 7.4 0 C8.8 0 10 1.2 10 2.8 C10 5 8 6.5 5 9 Z" />
</svg>
);
function NotifIcon({ type }: { type: Notification["type"] }) { function NotifIcon({ type }: { type: Notification["type"] }) {
const kind = notifIconKind(type); const kind = notifIconKind(type);
const glyphs: Record<NotifIconKind, React.ReactNode> = { const glyphs: Record<NotifIconKind, React.ReactNode> = {
@@ -85,6 +95,7 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
playlist: "📜", playlist: "📜",
mention: "@", mention: "@",
comment: "💬", comment: "💬",
like: <HeartSvg />,
}; };
return ( return (
<span className={`notif-icon notif-icon--${kind}`}> <span className={`notif-icon notif-icon--${kind}`}>
@@ -110,6 +121,10 @@ function notificationLink(n: Notification): string {
return `/dumps/${(data as DumpCommentedData).dumpId}#comment-${ return `/dumps/${(data as DumpCommentedData).dumpId}#comment-${
(data as DumpCommentedData).commentId (data as DumpCommentedData).commentId
}`; }`;
case "comment_liked":
return `/dumps/${(data as CommentLikedData).dumpId}#comment-${
(data as CommentLikedData).commentId
}`;
case "user_mentioned": { case "user_mentioned": {
const d = data as UserMentionedData; const d = data as UserMentionedData;
if (d.contextType === "comment") { if (d.contextType === "comment") {
@@ -183,6 +198,16 @@ function notificationContent(n: Notification): React.ReactNode {
</Trans> </Trans>
); );
} }
case "comment_liked": {
const d = data as CommentLikedData;
return (
<Trans>
<strong>{d.likerUsername}</strong>
{" liked your comment on "}
<strong>{d.dumpTitle}</strong>
</Trans>
);
}
case "user_mentioned": { case "user_mentioned": {
const d = data as UserMentionedData; const d = data as UserMentionedData;
const where = d.contextTitle || const where = d.contextTitle ||