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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
107
api/services/comment-like-service.ts
Normal file
107
api/services/comment-like-service.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
43
api/sql/migrate.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
api/sql/migrations/0001_comment_likes.ts
Normal file
27
api/sql/migrations/0001_comment_likes.ts
Normal 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);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
44
package.json
44
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
src/App.css
95
src/App.css
@@ -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));
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
57
src/components/LikeButton.tsx
Normal file
57
src/components/LikeButton.tsx
Normal 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`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/UserListPopover.tsx
Normal file
196
src/components/UserListPopover.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
43
src/model.ts
43
src/model.ts
@@ -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
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
Reference in New Issue
Block a user