v3: various improvements and fixes to real-time updates
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
This commit is contained in:
20
src/contexts/AvatarOverrideContext.ts
Normal file
20
src/contexts/AvatarOverrideContext.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
/**
|
||||
* Live avatar state learned from `user_updated` WS events, keyed by userId.
|
||||
* Kept in its own context (not WSContext) so that `Avatar` — rendered many
|
||||
* times across feeds and comment threads — only re-renders when an avatar
|
||||
* actually changes, not on every vote/comment/dump event.
|
||||
*
|
||||
* - `hasAvatar` reflects whether the user currently has an avatar (so a freshly
|
||||
* added or removed avatar flips even when the surrounding list data is stale).
|
||||
* - `version` cache-busts the avatar URL (derived from the user's `updatedAt`).
|
||||
*/
|
||||
export interface AvatarOverride {
|
||||
hasAvatar: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const AvatarOverrideContext = createContext<
|
||||
Record<string, AvatarOverride>
|
||||
>({});
|
||||
@@ -58,6 +58,8 @@ export interface WSContextValue {
|
||||
lastUserEvent: UserEvent | null;
|
||||
unreadNotificationCount: number;
|
||||
lastNotification: Notification | null;
|
||||
/** Increments on each WS reconnect so pages can backfill missed events. */
|
||||
connectionEpoch: number;
|
||||
castVote: (dumpId: string) => void;
|
||||
removeVote: (dumpId: string) => void;
|
||||
castCommentLike: (commentId: string) => void;
|
||||
@@ -85,6 +87,7 @@ export const WSContext = createContext<WSContextValue>({
|
||||
lastUserEvent: null,
|
||||
unreadNotificationCount: 0,
|
||||
lastNotification: null,
|
||||
connectionEpoch: 0,
|
||||
castVote: () => {},
|
||||
removeVote: () => {},
|
||||
castCommentLike: () => {},
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
WSContext,
|
||||
type WSContextValue,
|
||||
} from "./WSContext.ts";
|
||||
import {
|
||||
type AvatarOverride,
|
||||
AvatarOverrideContext,
|
||||
} from "./AvatarOverrideContext.ts";
|
||||
import { WS_URL } from "../config/api.ts";
|
||||
import type {
|
||||
Dump,
|
||||
@@ -41,6 +45,10 @@ interface WSProviderProps {
|
||||
const MAX_BACKOFF = 30_000;
|
||||
const ACK_TIMEOUT = 5_000;
|
||||
const CONNECT_TIMEOUT = 2_500;
|
||||
// Cap the live "new dumps" buffer so a long-lived session can't grow it without
|
||||
// bound. Feeds merge these on top of their own paginated list, so a generous
|
||||
// cap is plenty — older live arrivals are reachable via normal pagination.
|
||||
const MAX_RECENT_DUMPS = 100;
|
||||
|
||||
interface PendingVote {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
@@ -74,14 +82,10 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
>("connecting");
|
||||
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Reset status to "connecting" during render when token changes, rather than
|
||||
// inside the effect (which would cause a cascading re-render).
|
||||
// Tracks the token to detect login/logout below. The actual reset runs after
|
||||
// all state declarations (see the `prevToken !== token` block) because it
|
||||
// touches setters that aren't in scope yet here.
|
||||
const [prevToken, setPrevToken] = useState(token);
|
||||
if (prevToken !== token) {
|
||||
setPrevToken(token);
|
||||
setWSStatus("connecting");
|
||||
setWSErrorMessage(null);
|
||||
}
|
||||
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||
@@ -107,6 +111,42 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
||||
null,
|
||||
);
|
||||
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||
Record<string, AvatarOverride>
|
||||
>({});
|
||||
// Bumped each time the socket reconnects (a `welcome` after the first), so
|
||||
// pages can backfill events they missed while disconnected. Monotonic — not
|
||||
// reset on token change.
|
||||
const [connectionEpoch, setConnectionEpoch] = useState(0);
|
||||
|
||||
// Reset during render when the token changes (login/logout/account switch),
|
||||
// rather than inside the effect (which would cause a cascading re-render).
|
||||
// The socket reconnects and `welcome` re-syncs presence/votes/likes/unread,
|
||||
// but the accumulated realtime state below would otherwise bleed from the
|
||||
// previous session into the next (e.g. another account's recent dumps).
|
||||
if (prevToken !== token) {
|
||||
setPrevToken(token);
|
||||
setWSStatus("connecting");
|
||||
setWSErrorMessage(null);
|
||||
setOnlineUsers([]);
|
||||
setVoteCounts({});
|
||||
setMyVotes(new Set());
|
||||
setLikeCounts({});
|
||||
setMyLikes(new Set());
|
||||
setRecentDumps([]);
|
||||
setDeletedDumpIds(new Set());
|
||||
setLastVoteEvent(null);
|
||||
setLastLikeEvent(null);
|
||||
setLastDumpEvent(null);
|
||||
setLastPlaylistEvent(null);
|
||||
setDeletedPlaylistIds(new Set());
|
||||
setLastCommentEvent(null);
|
||||
setLastUserEvent(null);
|
||||
setUnreadNotificationCount(0);
|
||||
setLastNotification(null);
|
||||
setAvatarOverrides({});
|
||||
}
|
||||
|
||||
// Refs to avoid stale closures in event handlers
|
||||
const voteCountsRef = useRef(voteCounts);
|
||||
@@ -194,6 +234,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let everConnected = false;
|
||||
// Counts `welcome` messages for this token; the first is the initial
|
||||
// connect, any later one is a reconnect worth a backfill epoch bump.
|
||||
let welcomeCount = 0;
|
||||
|
||||
function connect() {
|
||||
if (closed) return;
|
||||
@@ -244,6 +287,10 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
// in-flight revert timers, they are now superseded.
|
||||
clearAllPendingVotes();
|
||||
clearAllPendingLikes();
|
||||
// Reconnect (not the first welcome): signal pages to backfill the
|
||||
// dumps/comments they couldn't receive while disconnected.
|
||||
welcomeCount += 1;
|
||||
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
|
||||
break;
|
||||
|
||||
case "presence_update":
|
||||
@@ -288,7 +335,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
|
||||
case "dump_created": {
|
||||
const dump = deserializeDump(msg.dump);
|
||||
setRecentDumps((prev) => [dump, ...prev]);
|
||||
setRecentDumps((prev) =>
|
||||
[dump, ...prev].slice(0, MAX_RECENT_DUMPS)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -305,7 +354,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
});
|
||||
// Add to live feed if not already present (private→public).
|
||||
setRecentDumps((prev) =>
|
||||
prev.some((d) => d.id === dump.id) ? prev : [dump, ...prev]
|
||||
prev.some((d) => d.id === dump.id)
|
||||
? prev
|
||||
: [dump, ...prev].slice(0, MAX_RECENT_DUMPS)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -378,6 +429,15 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
case "user_updated": {
|
||||
const user = deserializePublicUser(msg.user);
|
||||
setLastUserEvent({ user });
|
||||
// Refresh this user's avatar everywhere it's rendered. updatedAt
|
||||
// (bumped on any profile/avatar write) doubles as the version.
|
||||
setAvatarOverrides((prev) => ({
|
||||
...prev,
|
||||
[user.id]: {
|
||||
hasAvatar: !!user.avatarMime,
|
||||
version: user.updatedAt?.getTime() ?? Date.now(),
|
||||
},
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -418,10 +478,27 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
onForceLogoutRef.current();
|
||||
break;
|
||||
|
||||
case "error":
|
||||
// Vote errors currently don't identify which dump/action failed, so
|
||||
// fall back to the per-dump timeout rollback instead of guessing.
|
||||
case "error": {
|
||||
// When the server identifies the failed optimistic action, revert
|
||||
// its pending vote/like right away instead of waiting out the ACK
|
||||
// timeout. Without context, the per-action timer handles cleanup.
|
||||
if (msg.dumpId) {
|
||||
const pending = pendingRef.current.get(msg.dumpId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingRef.current.delete(msg.dumpId);
|
||||
pending.rollback();
|
||||
}
|
||||
} else if (msg.commentId) {
|
||||
const pending = pendingLikesRef.current.get(msg.commentId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingLikesRef.current.delete(msg.commentId);
|
||||
pending.rollback();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -619,7 +696,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
const injectDump = useCallback((dump: Dump) => {
|
||||
setRecentDumps((prev) => {
|
||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||
return [dump, ...prev];
|
||||
return [dump, ...prev].slice(0, MAX_RECENT_DUMPS);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -646,6 +723,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
lastUserEvent,
|
||||
unreadNotificationCount,
|
||||
lastNotification,
|
||||
connectionEpoch,
|
||||
castVote,
|
||||
removeVote,
|
||||
castCommentLike,
|
||||
@@ -671,6 +749,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
lastUserEvent,
|
||||
unreadNotificationCount,
|
||||
lastNotification,
|
||||
connectionEpoch,
|
||||
castVote,
|
||||
removeVote,
|
||||
castCommentLike,
|
||||
@@ -681,7 +760,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
|
||||
return (
|
||||
<WSContext.Provider value={value}>
|
||||
{children}
|
||||
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
||||
{children}
|
||||
</AvatarOverrideContext.Provider>
|
||||
</WSContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user