diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts
index 612f76b..9714e4b 100644
--- a/api/model/interfaces.ts
+++ b/api/model/interfaces.ts
@@ -658,6 +658,11 @@ export interface NotificationCreatedMessage {
export interface ErrorMessage {
type: "error";
message?: string;
+ // Context for optimistic-action failures so the client can revert the exact
+ // pending vote/like immediately instead of waiting for its ACK timeout.
+ dumpId?: string;
+ commentId?: string;
+ action?: "cast" | "remove";
}
export interface ForceLogoutMessage {
diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts
index 470bf11..0fd1636 100644
--- a/api/routes/avatars.ts
+++ b/api/routes/avatars.ts
@@ -1,7 +1,10 @@
import { Router } from "@oak/oak";
import { authMiddleware } from "../middleware/auth.ts";
import { getUserById, updateUserAvatar } from "../services/user-service.ts";
-import { updateClientAvatar } from "../services/ws-service.ts";
+import {
+ broadcastUserUpdated,
+ updateClientAvatar,
+} from "../services/ws-service.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import {
AVATARS_DIR,
@@ -45,6 +48,11 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
updateClientAvatar(authPayload.userId, mime);
const { passwordHash: _, ...publicUser } = getUserById(authPayload.userId);
+ // Let every client refresh this user's avatar wherever it's shown (feeds,
+ // comments, OP, profiles) — not just the presence row. updated_at, bumped on
+ // the avatar write, doubles as the cache-busting version.
+ const { email: _email, ...broadcastUser } = publicUser;
+ broadcastUserUpdated(broadcastUser);
ctx.response.status = 200;
ctx.response.body = { success: true, data: publicUser };
});
diff --git a/api/routes/ws.ts b/api/routes/ws.ts
index 7058391..9696604 100644
--- a/api/routes/ws.ts
+++ b/api/routes/ws.ts
@@ -153,7 +153,7 @@ function handleVote(
broadcastVoteUpdate(dumpId, newCount, client.userId, action);
} catch (err) {
const message = err instanceof APIException ? err.message : "Vote failed";
- socket.send(JSON.stringify({ type: "error", message }));
+ socket.send(JSON.stringify({ type: "error", message, dumpId, action }));
}
}
@@ -186,7 +186,7 @@ function handleCommentLike(
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action);
} catch (err) {
const message = err instanceof APIException ? err.message : "Like failed";
- socket.send(JSON.stringify({ type: "error", message }));
+ socket.send(JSON.stringify({ type: "error", message, commentId, action }));
}
}
diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts
index cb3ae10..51a8868 100644
--- a/api/services/notification-service.ts
+++ b/api/services/notification-service.ts
@@ -169,13 +169,18 @@ export function notifyUserFollowersNewDump(
const createdAt = new Date().toISOString();
const sourceKey = `dump:${dumpId}`;
- // Batch INSERT all follower notifications in a single statement
- const params: (string | number | null)[] = [];
- const placeholders: string[] = [];
+ // Insert per follower so we can broadcast only the rows that were actually
+ // created (INSERT OR IGNORE skips followers who already have this dump's
+ // notification) and reuse each persisted id in the live event — otherwise a
+ // later refetch would dedupe against a mismatched id and show a duplicate.
+ const insert = db.prepare(
+ `INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
+ VALUES (?, ?, ?, ?, 0, ?, ?);`,
+ );
+
for (const row of followerRows) {
const id = crypto.randomUUID();
- placeholders.push("(?, ?, ?, ?, 0, ?, ?)");
- params.push(
+ const result = insert.run(
id,
row.follower_id,
"user_dump_posted",
@@ -183,19 +188,11 @@ export function notifyUserFollowersNewDump(
createdAt,
sourceKey,
);
- }
-
- const result = db.prepare(
- `INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
- VALUES ${placeholders.join(", ")};`,
- ).run(...params);
-
- if ((result.changes as number) > 0) {
- for (const row of followerRows) {
+ if ((result.changes as number) > 0) {
sendToUser(row.follower_id, {
type: "notification_created",
notification: {
- id: crypto.randomUUID(),
+ id,
userId: row.follower_id,
type: "user_dump_posted",
data,
diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx
index 386d868..5104a3d 100644
--- a/src/components/Avatar.tsx
+++ b/src/components/Avatar.tsx
@@ -1,5 +1,6 @@
-import { useState } from "react";
+import { useContext, useState } from "react";
import { API_URL } from "../config/api.ts";
+import { AvatarOverrideContext } from "../contexts/AvatarOverrideContext.ts";
interface AvatarProps {
userId: string;
@@ -15,9 +16,24 @@ export function Avatar(
const [imgFailed, setImgFailed] = useState(false);
const sizeStyle = { width: size, height: size };
- if (hasAvatar && !imgFailed) {
- const src = version
- ? `${API_URL}/api/avatars/${userId}?v=${version}`
+ // A live `user_updated` override (avatar add/remove/change) wins over the
+ // possibly-stale props the surrounding list was rendered with.
+ const override = useContext(AvatarOverrideContext)[userId];
+ const effectiveHasAvatar = override ? override.hasAvatar : hasAvatar;
+ const effectiveVersion = override ? override.version : version;
+
+ // A new image (different version/presence) is a fresh thing to try — clear
+ // any prior load failure during render rather than in an effect.
+ const imgKey = `${effectiveHasAvatar}:${effectiveVersion ?? ""}`;
+ const [prevImgKey, setPrevImgKey] = useState(imgKey);
+ if (prevImgKey !== imgKey) {
+ setPrevImgKey(imgKey);
+ setImgFailed(false);
+ }
+
+ if (effectiveHasAvatar && !imgFailed) {
+ const src = effectiveVersion
+ ? `${API_URL}/api/avatars/${userId}?v=${effectiveVersion}`
: `${API_URL}/api/avatars/${userId}`;
return (
+>({});
diff --git a/src/contexts/WSContext.ts b/src/contexts/WSContext.ts
index a71a01e..b8fa3b7 100644
--- a/src/contexts/WSContext.ts
+++ b/src/contexts/WSContext.ts
@@ -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({
lastUserEvent: null,
unreadNotificationCount: 0,
lastNotification: null,
+ connectionEpoch: 0,
castVote: () => {},
removeVote: () => {},
castCommentLike: () => {},
diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx
index 4eb9036..7a78cc9 100644
--- a/src/contexts/WSProvider.tsx
+++ b/src/contexts/WSProvider.tsx
@@ -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;
@@ -74,14 +82,10 @@ export function WSProvider({ children }: WSProviderProps) {
>("connecting");
const [wsErrorMessage, setWSErrorMessage] = useState(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([]);
const [voteCounts, setVoteCounts] = useState>({});
@@ -107,6 +111,42 @@ export function WSProvider({ children }: WSProviderProps) {
const [lastNotification, setLastNotification] = useState(
null,
);
+ // Live avatar overrides for site-wide Avatar refresh (own narrow context).
+ const [avatarOverrides, setAvatarOverrides] = useState<
+ Record
+ >({});
+ // 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 | null = null;
let connectTimeout: ReturnType | 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 (
- {children}
+
+ {children}
+
);
}
diff --git a/src/hooks/useDumpListSync.ts b/src/hooks/useDumpListSync.ts
index 3891910..1e5f240 100644
--- a/src/hooks/useDumpListSync.ts
+++ b/src/hooks/useDumpListSync.ts
@@ -22,12 +22,13 @@ interface DumpListSyncOptions {
* - deletedDumpIds growing → filter
* - lastDumpEvent "updated" → update in-place, or remove if now private
* - lastDumpEvent for a not-in-list dump → prepend if ownerId matches and visible
+ * - lastCommentEvent created/deleted → adjust the dump's commentCount
*/
export function useDumpListSync(
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
options?: DumpListSyncOptions,
): void {
- const { deletedDumpIds, lastDumpEvent } = useWS();
+ const { deletedDumpIds, lastDumpEvent, lastCommentEvent } = useWS();
// Keep refs up-to-date so closures in effects are never stale.
const setDumpsRef = useRef(setDumps);
@@ -69,4 +70,23 @@ export function useDumpListSync(
return [dump, ...prev];
});
}, [lastDumpEvent]);
+
+ // commentCount counts non-deleted comments, so created → +1, deleted → −1.
+ // Edits don't change the count. Events only fire for public dumps.
+ useEffect(() => {
+ if (!lastCommentEvent) return;
+ const { type, dumpId } = lastCommentEvent;
+ if (type !== "created" && type !== "deleted") return;
+ const delta = type === "created" ? 1 : -1;
+ setDumpsRef.current((prev) => {
+ const idx = prev.findIndex((d) => d.id === dumpId);
+ if (idx === -1) return prev;
+ const next = [...prev];
+ next[idx] = {
+ ...next[idx],
+ commentCount: Math.max(0, next[idx].commentCount + delta),
+ };
+ return next;
+ });
+ }, [lastCommentEvent]);
}
diff --git a/src/model.ts b/src/model.ts
index 986db19..d5ceb33 100644
--- a/src/model.ts
+++ b/src/model.ts
@@ -487,6 +487,11 @@ export interface WSNotificationCreatedMessage {
export interface WSErrorMessage {
type: "error";
message?: string;
+ // Context for optimistic-action failures so the client can revert the exact
+ // pending vote/like immediately instead of waiting for its ACK timeout.
+ dumpId?: string;
+ commentId?: string;
+ action?: "cast" | "remove";
}
export interface WSForceLogoutMessage {
diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx
index b0012a0..a022e2f 100644
--- a/src/pages/Dump.tsx
+++ b/src/pages/Dump.tsx
@@ -82,6 +82,7 @@ export function Dump() {
removeVote,
lastDumpEvent,
lastCommentEvent,
+ connectionEpoch,
} = useWS();
useEffect(() => {
@@ -157,7 +158,9 @@ export function Dump() {
})
.catch(() => {});
return () => controller.abort();
- }, [selectedDump, token]);
+ // connectionEpoch: re-fetch on reconnect to backfill comments missed while
+ // the socket was down.
+ }, [selectedDump, token, connectionEpoch]);
// Fetch related dumps (backlinks) when dump loads
useEffect(() => {
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx
index 34663c6..653a3bb 100644
--- a/src/pages/Index.tsx
+++ b/src/pages/Index.tsx
@@ -70,6 +70,7 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
deletedDumpIds,
castVote,
removeVote,
+ connectionEpoch,
} = useWS();
// Resolve the scope to a real category (for its id, name, and 404 handling).
@@ -177,6 +178,36 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
);
useDumpListSync(setDumpsItems);
+ // ── Reconnect backfill ──
+
+ // New dumps that arrived while the socket was down weren't delivered. On
+ // reconnect, re-fetch page 1 and merge anything not already present on top,
+ // preserving loaded pages and scroll position.
+ const backfillEpochRef = useRef(connectionEpoch);
+ useEffect(() => {
+ if (connectionEpoch === backfillEpochRef.current) return;
+ backfillEpochRef.current = connectionEpoch;
+ const controller = new AbortController();
+ fetch(feedUrl(1), {
+ signal: controller.signal,
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ })
+ .then((r) => r.json())
+ .then((body) => {
+ const { items } = body.data as PaginatedData;
+ const fresh = items.map(deserializeDump);
+ setDumpsState((s) => {
+ if (s.status !== "loaded") return s;
+ const known = new Set(s.dumps.map((d) => d.id));
+ const newOnes = fresh.filter((d) => !known.has(d.id));
+ if (newOnes.length === 0) return s;
+ return { ...s, dumps: [...newOnes, ...s.dumps] };
+ });
+ })
+ .catch(() => {});
+ return () => controller.abort();
+ }, [connectionEpoch, feedUrl, token]);
+
// ── Load more ──
const loadMore = useCallback(() => {