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(() => {