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

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

View File

@@ -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,
]);