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:
@@ -658,6 +658,11 @@ export interface NotificationCreatedMessage {
|
|||||||
export interface ErrorMessage {
|
export interface ErrorMessage {
|
||||||
type: "error";
|
type: "error";
|
||||||
message?: string;
|
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 {
|
export interface ForceLogoutMessage {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import { getUserById, updateUserAvatar } from "../services/user-service.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 { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
import {
|
import {
|
||||||
AVATARS_DIR,
|
AVATARS_DIR,
|
||||||
@@ -45,6 +48,11 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
|||||||
updateClientAvatar(authPayload.userId, mime);
|
updateClientAvatar(authPayload.userId, mime);
|
||||||
|
|
||||||
const { passwordHash: _, ...publicUser } = getUserById(authPayload.userId);
|
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.status = 200;
|
||||||
ctx.response.body = { success: true, data: publicUser };
|
ctx.response.body = { success: true, data: publicUser };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function handleVote(
|
|||||||
broadcastVoteUpdate(dumpId, newCount, client.userId, action);
|
broadcastVoteUpdate(dumpId, newCount, client.userId, action);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof APIException ? err.message : "Vote failed";
|
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);
|
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof APIException ? err.message : "Like failed";
|
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 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,13 +169,18 @@ export function notifyUserFollowersNewDump(
|
|||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
const sourceKey = `dump:${dumpId}`;
|
const sourceKey = `dump:${dumpId}`;
|
||||||
|
|
||||||
// Batch INSERT all follower notifications in a single statement
|
// Insert per follower so we can broadcast only the rows that were actually
|
||||||
const params: (string | number | null)[] = [];
|
// created (INSERT OR IGNORE skips followers who already have this dump's
|
||||||
const placeholders: string[] = [];
|
// 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) {
|
for (const row of followerRows) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
placeholders.push("(?, ?, ?, ?, 0, ?, ?)");
|
const result = insert.run(
|
||||||
params.push(
|
|
||||||
id,
|
id,
|
||||||
row.follower_id,
|
row.follower_id,
|
||||||
"user_dump_posted",
|
"user_dump_posted",
|
||||||
@@ -183,19 +188,11 @@ export function notifyUserFollowersNewDump(
|
|||||||
createdAt,
|
createdAt,
|
||||||
sourceKey,
|
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) {
|
if ((result.changes as number) > 0) {
|
||||||
for (const row of followerRows) {
|
|
||||||
sendToUser(row.follower_id, {
|
sendToUser(row.follower_id, {
|
||||||
type: "notification_created",
|
type: "notification_created",
|
||||||
notification: {
|
notification: {
|
||||||
id: crypto.randomUUID(),
|
id,
|
||||||
userId: row.follower_id,
|
userId: row.follower_id,
|
||||||
type: "user_dump_posted",
|
type: "user_dump_posted",
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import { AvatarOverrideContext } from "../contexts/AvatarOverrideContext.ts";
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -15,9 +16,24 @@ export function Avatar(
|
|||||||
const [imgFailed, setImgFailed] = useState(false);
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
const sizeStyle = { width: size, height: size };
|
const sizeStyle = { width: size, height: size };
|
||||||
|
|
||||||
if (hasAvatar && !imgFailed) {
|
// A live `user_updated` override (avatar add/remove/change) wins over the
|
||||||
const src = version
|
// possibly-stale props the surrounding list was rendered with.
|
||||||
? `${API_URL}/api/avatars/${userId}?v=${version}`
|
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}`;
|
: `${API_URL}/api/avatars/${userId}`;
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
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;
|
lastUserEvent: UserEvent | null;
|
||||||
unreadNotificationCount: number;
|
unreadNotificationCount: number;
|
||||||
lastNotification: Notification | null;
|
lastNotification: Notification | null;
|
||||||
|
/** Increments on each WS reconnect so pages can backfill missed events. */
|
||||||
|
connectionEpoch: number;
|
||||||
castVote: (dumpId: string) => void;
|
castVote: (dumpId: string) => void;
|
||||||
removeVote: (dumpId: string) => void;
|
removeVote: (dumpId: string) => void;
|
||||||
castCommentLike: (commentId: string) => void;
|
castCommentLike: (commentId: string) => void;
|
||||||
@@ -85,6 +87,7 @@ export const WSContext = createContext<WSContextValue>({
|
|||||||
lastUserEvent: null,
|
lastUserEvent: null,
|
||||||
unreadNotificationCount: 0,
|
unreadNotificationCount: 0,
|
||||||
lastNotification: null,
|
lastNotification: null,
|
||||||
|
connectionEpoch: 0,
|
||||||
castVote: () => {},
|
castVote: () => {},
|
||||||
removeVote: () => {},
|
removeVote: () => {},
|
||||||
castCommentLike: () => {},
|
castCommentLike: () => {},
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
WSContext,
|
WSContext,
|
||||||
type WSContextValue,
|
type WSContextValue,
|
||||||
} from "./WSContext.ts";
|
} from "./WSContext.ts";
|
||||||
|
import {
|
||||||
|
type AvatarOverride,
|
||||||
|
AvatarOverrideContext,
|
||||||
|
} from "./AvatarOverrideContext.ts";
|
||||||
import { WS_URL } from "../config/api.ts";
|
import { WS_URL } from "../config/api.ts";
|
||||||
import type {
|
import type {
|
||||||
Dump,
|
Dump,
|
||||||
@@ -41,6 +45,10 @@ interface WSProviderProps {
|
|||||||
const MAX_BACKOFF = 30_000;
|
const MAX_BACKOFF = 30_000;
|
||||||
const ACK_TIMEOUT = 5_000;
|
const ACK_TIMEOUT = 5_000;
|
||||||
const CONNECT_TIMEOUT = 2_500;
|
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 {
|
interface PendingVote {
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
@@ -74,14 +82,10 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
>("connecting");
|
>("connecting");
|
||||||
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Reset status to "connecting" during render when token changes, rather than
|
// Tracks the token to detect login/logout below. The actual reset runs after
|
||||||
// inside the effect (which would cause a cascading re-render).
|
// all state declarations (see the `prevToken !== token` block) because it
|
||||||
|
// touches setters that aren't in scope yet here.
|
||||||
const [prevToken, setPrevToken] = useState(token);
|
const [prevToken, setPrevToken] = useState(token);
|
||||||
if (prevToken !== token) {
|
|
||||||
setPrevToken(token);
|
|
||||||
setWSStatus("connecting");
|
|
||||||
setWSErrorMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||||
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||||
@@ -107,6 +111,42 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
||||||
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
|
// Refs to avoid stale closures in event handlers
|
||||||
const voteCountsRef = useRef(voteCounts);
|
const voteCountsRef = useRef(voteCounts);
|
||||||
@@ -194,6 +234,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let everConnected = false;
|
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() {
|
function connect() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
@@ -244,6 +287,10 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
// in-flight revert timers, they are now superseded.
|
// in-flight revert timers, they are now superseded.
|
||||||
clearAllPendingVotes();
|
clearAllPendingVotes();
|
||||||
clearAllPendingLikes();
|
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;
|
break;
|
||||||
|
|
||||||
case "presence_update":
|
case "presence_update":
|
||||||
@@ -288,7 +335,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
|
|
||||||
case "dump_created": {
|
case "dump_created": {
|
||||||
const dump = deserializeDump(msg.dump);
|
const dump = deserializeDump(msg.dump);
|
||||||
setRecentDumps((prev) => [dump, ...prev]);
|
setRecentDumps((prev) =>
|
||||||
|
[dump, ...prev].slice(0, MAX_RECENT_DUMPS)
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +354,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
});
|
});
|
||||||
// Add to live feed if not already present (private→public).
|
// Add to live feed if not already present (private→public).
|
||||||
setRecentDumps((prev) =>
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -378,6 +429,15 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
case "user_updated": {
|
case "user_updated": {
|
||||||
const user = deserializePublicUser(msg.user);
|
const user = deserializePublicUser(msg.user);
|
||||||
setLastUserEvent({ 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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,11 +478,28 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
onForceLogoutRef.current();
|
onForceLogoutRef.current();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error": {
|
||||||
// Vote errors currently don't identify which dump/action failed, so
|
// When the server identifies the failed optimistic action, revert
|
||||||
// fall back to the per-dump timeout rollback instead of guessing.
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -619,7 +696,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
const injectDump = useCallback((dump: Dump) => {
|
const injectDump = useCallback((dump: Dump) => {
|
||||||
setRecentDumps((prev) => {
|
setRecentDumps((prev) => {
|
||||||
if (prev.some((d) => d.id === dump.id)) return 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,
|
lastUserEvent,
|
||||||
unreadNotificationCount,
|
unreadNotificationCount,
|
||||||
lastNotification,
|
lastNotification,
|
||||||
|
connectionEpoch,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
castCommentLike,
|
castCommentLike,
|
||||||
@@ -671,6 +749,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
lastUserEvent,
|
lastUserEvent,
|
||||||
unreadNotificationCount,
|
unreadNotificationCount,
|
||||||
lastNotification,
|
lastNotification,
|
||||||
|
connectionEpoch,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
castCommentLike,
|
castCommentLike,
|
||||||
@@ -681,7 +760,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WSContext.Provider value={value}>
|
<WSContext.Provider value={value}>
|
||||||
|
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
||||||
{children}
|
{children}
|
||||||
|
</AvatarOverrideContext.Provider>
|
||||||
</WSContext.Provider>
|
</WSContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ interface DumpListSyncOptions {
|
|||||||
* - deletedDumpIds growing → filter
|
* - deletedDumpIds growing → filter
|
||||||
* - lastDumpEvent "updated" → update in-place, or remove if now private
|
* - lastDumpEvent "updated" → update in-place, or remove if now private
|
||||||
* - lastDumpEvent for a not-in-list dump → prepend if ownerId matches and visible
|
* - lastDumpEvent for a not-in-list dump → prepend if ownerId matches and visible
|
||||||
|
* - lastCommentEvent created/deleted → adjust the dump's commentCount
|
||||||
*/
|
*/
|
||||||
export function useDumpListSync(
|
export function useDumpListSync(
|
||||||
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
||||||
options?: DumpListSyncOptions,
|
options?: DumpListSyncOptions,
|
||||||
): void {
|
): void {
|
||||||
const { deletedDumpIds, lastDumpEvent } = useWS();
|
const { deletedDumpIds, lastDumpEvent, lastCommentEvent } = useWS();
|
||||||
|
|
||||||
// Keep refs up-to-date so closures in effects are never stale.
|
// Keep refs up-to-date so closures in effects are never stale.
|
||||||
const setDumpsRef = useRef(setDumps);
|
const setDumpsRef = useRef(setDumps);
|
||||||
@@ -69,4 +70,23 @@ export function useDumpListSync(
|
|||||||
return [dump, ...prev];
|
return [dump, ...prev];
|
||||||
});
|
});
|
||||||
}, [lastDumpEvent]);
|
}, [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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -487,6 +487,11 @@ export interface WSNotificationCreatedMessage {
|
|||||||
export interface WSErrorMessage {
|
export interface WSErrorMessage {
|
||||||
type: "error";
|
type: "error";
|
||||||
message?: string;
|
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 {
|
export interface WSForceLogoutMessage {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function Dump() {
|
|||||||
removeVote,
|
removeVote,
|
||||||
lastDumpEvent,
|
lastDumpEvent,
|
||||||
lastCommentEvent,
|
lastCommentEvent,
|
||||||
|
connectionEpoch,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,7 +158,9 @@ export function Dump() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => controller.abort();
|
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
|
// Fetch related dumps (backlinks) when dump loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
|
|||||||
deletedDumpIds,
|
deletedDumpIds,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
|
connectionEpoch,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
|
|
||||||
// Resolve the scope to a real category (for its id, name, and 404 handling).
|
// 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);
|
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<RawDump>;
|
||||||
|
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 ──
|
// ── Load more ──
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user