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:
95
src/App.css
95
src/App.css
@@ -1272,6 +1272,19 @@ body.has-player .fab-new {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -2176,6 +2189,63 @@ body.has-player .fab-new {
|
||||
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 {
|
||||
width: 100%;
|
||||
@@ -3560,6 +3630,31 @@ body.has-player .fab-new {
|
||||
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 {
|
||||
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));
|
||||
|
||||
@@ -14,10 +14,12 @@ import { deserializeComment, parseAPIResponse } from "../model.ts";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
||||
import { LikeButton } from "./LikeButton.tsx";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
|
||||
interface CommentThreadProps {
|
||||
dumpId: string;
|
||||
@@ -75,6 +77,9 @@ function CommentNode({
|
||||
const replyEditorRef = useRef<TextEditorHandle>(null);
|
||||
const editEditorRef = useRef<TextEditorHandle>(null);
|
||||
|
||||
const { likeCounts, myLikes, castCommentLike, removeCommentLike } =
|
||||
useWS();
|
||||
|
||||
const children = tree.get(comment.id) ?? [];
|
||||
|
||||
async function handleReply(e?: React.SubmitEvent) {
|
||||
@@ -285,6 +290,14 @@ function CommentNode({
|
||||
)
|
||||
: <Markdown className="comment-body">{comment.body}</Markdown>}
|
||||
<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 && (
|
||||
<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 {
|
||||
dumpId: string;
|
||||
count: number;
|
||||
@@ -10,16 +14,42 @@ interface VoteButtonProps {
|
||||
export function VoteButton(
|
||||
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
|
||||
) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const countRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
|
||||
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
|
||||
disabled={disabled}
|
||||
aria-label={voted ? "Remove vote" : "Upvote"}
|
||||
title={disabled ? "Log in to vote" : undefined}
|
||||
>
|
||||
▲ {count}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
|
||||
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
|
||||
disabled={disabled}
|
||||
aria-label={voted ? "Remove vote" : "Upvote"}
|
||||
title={disabled ? "Log in to vote" : undefined}
|
||||
>
|
||||
▲{" "}
|
||||
{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";
|
||||
}
|
||||
|
||||
export interface LikeEvent {
|
||||
commentId: string;
|
||||
likerId: string;
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
export interface PlaylistEvent {
|
||||
type: "created" | "updated" | "deleted" | "dumps_updated";
|
||||
playlistId: string;
|
||||
@@ -39,9 +45,12 @@ export interface WSContextValue {
|
||||
onlineUsers: OnlineUser[];
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
likeCounts: Record<string, number>;
|
||||
myLikes: Set<string>;
|
||||
recentDumps: Dump[];
|
||||
deletedDumpIds: Set<string>;
|
||||
lastVoteEvent: VoteEvent | null;
|
||||
lastLikeEvent: LikeEvent | null;
|
||||
lastDumpEvent: Dump | null;
|
||||
lastPlaylistEvent: PlaylistEvent | null;
|
||||
deletedPlaylistIds: Set<string>;
|
||||
@@ -51,6 +60,8 @@ export interface WSContextValue {
|
||||
lastNotification: Notification | null;
|
||||
castVote: (dumpId: string) => void;
|
||||
removeVote: (dumpId: string) => void;
|
||||
castCommentLike: (commentId: string) => void;
|
||||
removeCommentLike: (commentId: string) => void;
|
||||
injectDump: (dump: Dump) => void;
|
||||
clearUnreadNotifications: () => void;
|
||||
}
|
||||
@@ -61,9 +72,12 @@ export const WSContext = createContext<WSContextValue>({
|
||||
onlineUsers: [],
|
||||
voteCounts: {},
|
||||
myVotes: new Set(),
|
||||
likeCounts: {},
|
||||
myLikes: new Set(),
|
||||
recentDumps: [],
|
||||
deletedDumpIds: new Set(),
|
||||
lastVoteEvent: null,
|
||||
lastLikeEvent: null,
|
||||
lastDumpEvent: null,
|
||||
lastPlaylistEvent: null,
|
||||
deletedPlaylistIds: new Set(),
|
||||
@@ -73,6 +87,8 @@ export const WSContext = createContext<WSContextValue>({
|
||||
lastNotification: null,
|
||||
castVote: () => {},
|
||||
removeVote: () => {},
|
||||
castCommentLike: () => {},
|
||||
removeCommentLike: () => {},
|
||||
injectDump: () => {},
|
||||
clearUnreadNotifications: () => {},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
type CommentEvent,
|
||||
type LikeEvent,
|
||||
type PlaylistEvent,
|
||||
type UserEvent,
|
||||
type VoteEvent,
|
||||
@@ -46,6 +47,11 @@ interface PendingVote {
|
||||
rollback: () => void;
|
||||
}
|
||||
|
||||
interface PendingLike {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
rollback: () => void;
|
||||
}
|
||||
|
||||
// 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.
|
||||
function parseWSMessage(data: string): IncomingWSMessage | null {
|
||||
@@ -80,9 +86,12 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||
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 [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
||||
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
|
||||
const [lastLikeEvent, setLastLikeEvent] = useState<LikeEvent | null>(null);
|
||||
const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
|
||||
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
|
||||
PlaylistEvent | null
|
||||
@@ -102,6 +111,8 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
// Refs to avoid stale closures in event handlers
|
||||
const voteCountsRef = useRef(voteCounts);
|
||||
const myVotesRef = useRef(myVotes);
|
||||
const likeCountsRef = useRef(likeCounts);
|
||||
const myLikesRef = useRef(myLikes);
|
||||
const userIdRef = useRef(userId);
|
||||
// Stable ref for logout so the effect doesn't reconnect when the function
|
||||
// reference changes on re-renders.
|
||||
@@ -109,6 +120,8 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
useLayoutEffect(() => {
|
||||
voteCountsRef.current = voteCounts;
|
||||
myVotesRef.current = myVotes;
|
||||
likeCountsRef.current = likeCounts;
|
||||
myLikesRef.current = myLikes;
|
||||
userIdRef.current = userId;
|
||||
onForceLogoutRef.current = logout;
|
||||
});
|
||||
@@ -118,6 +131,10 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||
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 pending = pendingRef.current.get(dumpId);
|
||||
@@ -145,6 +162,32 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
pendingRef.current.set(dumpId, { timeout, rollback });
|
||||
}, [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(() => {
|
||||
let closed = false;
|
||||
let backoff = 500;
|
||||
@@ -195,10 +238,12 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
backoff = 500; // reset backoff on successful connect
|
||||
setOnlineUsers(msg.users);
|
||||
setMyVotes(new Set(msg.myVotes));
|
||||
setMyLikes(new Set(msg.myCommentLikes));
|
||||
setUnreadNotificationCount(msg.unreadNotificationCount);
|
||||
// welcome provides authoritative server state — cancel any
|
||||
// in-flight revert timers, they are now superseded.
|
||||
clearAllPendingVotes();
|
||||
clearAllPendingLikes();
|
||||
break;
|
||||
|
||||
case "presence_update":
|
||||
@@ -223,6 +268,24 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
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": {
|
||||
const dump = deserializeDump(msg.dump);
|
||||
setRecentDumps((prev) => [dump, ...prev]);
|
||||
@@ -269,6 +332,21 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
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_updated": {
|
||||
const playlist = deserializePlaylist(msg.playlist);
|
||||
@@ -373,6 +451,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
connect();
|
||||
|
||||
const pending = pendingRef.current;
|
||||
const pendingLikes = pendingLikesRef.current;
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
@@ -383,8 +462,18 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
clearTimeout(pendingVote.timeout);
|
||||
}
|
||||
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) => {
|
||||
// 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
|
||||
}, [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) => {
|
||||
setRecentDumps((prev) => {
|
||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||
@@ -472,9 +633,12 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
onlineUsers,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
likeCounts,
|
||||
myLikes,
|
||||
recentDumps,
|
||||
deletedDumpIds,
|
||||
lastVoteEvent,
|
||||
lastLikeEvent,
|
||||
lastDumpEvent,
|
||||
lastPlaylistEvent,
|
||||
deletedPlaylistIds,
|
||||
@@ -484,6 +648,8 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
lastNotification,
|
||||
castVote,
|
||||
removeVote,
|
||||
castCommentLike,
|
||||
removeCommentLike,
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
}), [
|
||||
@@ -492,9 +658,12 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
onlineUsers,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
likeCounts,
|
||||
myLikes,
|
||||
recentDumps,
|
||||
deletedDumpIds,
|
||||
lastVoteEvent,
|
||||
lastLikeEvent,
|
||||
lastDumpEvent,
|
||||
lastPlaylistEvent,
|
||||
deletedPlaylistIds,
|
||||
@@ -504,6 +673,8 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
lastNotification,
|
||||
castVote,
|
||||
removeVote,
|
||||
castCommentLike,
|
||||
removeCommentLike,
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
]);
|
||||
|
||||
43
src/model.ts
43
src/model.ts
@@ -162,6 +162,7 @@ export interface Comment {
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
deleted: boolean;
|
||||
likeCount: number;
|
||||
authorUsername: string;
|
||||
authorAvatarMime?: string;
|
||||
}
|
||||
@@ -249,7 +250,8 @@ export type NotificationType =
|
||||
| "playlist_dump_added"
|
||||
| "dump_upvoted"
|
||||
| "user_mentioned"
|
||||
| "dump_commented";
|
||||
| "dump_commented"
|
||||
| "comment_liked";
|
||||
|
||||
export interface PlaylistFollowedData {
|
||||
followerId: string;
|
||||
@@ -301,6 +303,14 @@ export interface DumpCommentedData {
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
export interface CommentLikedData {
|
||||
likerId: string;
|
||||
likerUsername: string;
|
||||
commentId: string;
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
export type NotificationData =
|
||||
| PlaylistFollowedData
|
||||
| UserFollowedData
|
||||
@@ -308,7 +318,8 @@ export type NotificationData =
|
||||
| PlaylistDumpAddedData
|
||||
| DumpUpvotedData
|
||||
| UserMentionedData
|
||||
| DumpCommentedData;
|
||||
| DumpCommentedData
|
||||
| CommentLikedData;
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
@@ -350,6 +361,7 @@ export interface WSWelcomeMessage {
|
||||
type: "welcome";
|
||||
users: OnlineUser[];
|
||||
myVotes: string[];
|
||||
myCommentLikes: string[];
|
||||
unreadNotificationCount: number;
|
||||
}
|
||||
export interface WSPresenceUpdateMessage {
|
||||
@@ -369,6 +381,19 @@ export interface WSVoteAckMessage {
|
||||
action: "cast" | "remove";
|
||||
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 {
|
||||
type: "dump_created";
|
||||
dump: RawDump;
|
||||
@@ -435,6 +460,8 @@ export type IncomingWSMessage =
|
||||
| WSPresenceUpdateMessage
|
||||
| WSVotesUpdateMessage
|
||||
| WSVoteAckMessage
|
||||
| WSCommentLikesUpdateMessage
|
||||
| WSCommentLikeAckMessage
|
||||
| WSDumpCreatedMessage
|
||||
| WSDumpUpdatedMessage
|
||||
| WSDumpDeletedMessage
|
||||
@@ -465,11 +492,21 @@ export interface WSVoteRemoveMessage {
|
||||
type: "vote_remove";
|
||||
dumpId: string;
|
||||
}
|
||||
export interface WSCommentLikeCastMessage {
|
||||
type: "comment_like_cast";
|
||||
commentId: string;
|
||||
}
|
||||
export interface WSCommentLikeRemoveMessage {
|
||||
type: "comment_like_remove";
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
export type OutgoingWSMessage =
|
||||
| WSPongMessage
|
||||
| WSVoteCastMessage
|
||||
| WSVoteRemoveMessage;
|
||||
| WSVoteRemoveMessage
|
||||
| WSCommentLikeCastMessage
|
||||
| WSCommentLikeRemoveMessage;
|
||||
|
||||
/**
|
||||
* Follows
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import type {
|
||||
CommentLikedData,
|
||||
DumpCommentedData,
|
||||
DumpUpvotedData,
|
||||
Notification,
|
||||
@@ -43,7 +44,8 @@ type NotifIconKind =
|
||||
| "dump"
|
||||
| "playlist"
|
||||
| "mention"
|
||||
| "comment";
|
||||
| "comment"
|
||||
| "like";
|
||||
|
||||
function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
switch (type) {
|
||||
@@ -61,6 +63,8 @@ function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
return "mention";
|
||||
case "dump_commented":
|
||||
return "comment";
|
||||
case "comment_liked":
|
||||
return "like";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +80,12 @@ const FollowSvg = () => (
|
||||
</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"] }) {
|
||||
const kind = notifIconKind(type);
|
||||
const glyphs: Record<NotifIconKind, React.ReactNode> = {
|
||||
@@ -85,6 +95,7 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
playlist: "📜",
|
||||
mention: "@",
|
||||
comment: "💬",
|
||||
like: <HeartSvg />,
|
||||
};
|
||||
return (
|
||||
<span className={`notif-icon notif-icon--${kind}`}>
|
||||
@@ -110,6 +121,10 @@ function notificationLink(n: Notification): string {
|
||||
return `/dumps/${(data as DumpCommentedData).dumpId}#comment-${
|
||||
(data as DumpCommentedData).commentId
|
||||
}`;
|
||||
case "comment_liked":
|
||||
return `/dumps/${(data as CommentLikedData).dumpId}#comment-${
|
||||
(data as CommentLikedData).commentId
|
||||
}`;
|
||||
case "user_mentioned": {
|
||||
const d = data as UserMentionedData;
|
||||
if (d.contextType === "comment") {
|
||||
@@ -183,6 +198,16 @@ function notificationContent(n: Notification): React.ReactNode {
|
||||
</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": {
|
||||
const d = data as UserMentionedData;
|
||||
const where = d.contextTitle ||
|
||||
|
||||
Reference in New Issue
Block a user