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:
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user