1226 lines
39 KiB
TypeScript
1226 lines
39 KiB
TypeScript
/**
|
|
* Brings the server's live updates into the app. It holds the one WebSocket to
|
|
* `/ws` and turns its messages into the state of `WSContext`, `WSChatContext`,
|
|
* `WSPresenceContext` and `AvatarOverrideContext`. `App` mounts it around every
|
|
* page. The protocol is `shared/ws.ts`, and the server's side is
|
|
* `api/routes/ws.ts`.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import {
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
type ChatError,
|
|
type ChatReplyTarget,
|
|
type CommentEvent,
|
|
type PendingChatMessage,
|
|
type PlaylistEvent,
|
|
type UserEvent,
|
|
type VoteEvent,
|
|
WSChatContext,
|
|
type WSChatContextValue,
|
|
WSContext,
|
|
type WSContextValue,
|
|
WSPresenceContext,
|
|
type WSPresenceContextValue,
|
|
} from "./WSContext.ts";
|
|
import {
|
|
type AvatarOverride,
|
|
AvatarOverrideContext,
|
|
} from "./AvatarOverrideContext.ts";
|
|
import { WS_URL } from "../../config/api.ts";
|
|
import type { ChatMessage } from "../../../shared/chat.ts";
|
|
import type { Dump } from "../../../shared/dump.ts";
|
|
import type { Wire } from "../../../shared/api.ts";
|
|
import type {
|
|
ClientToServerMessage,
|
|
OnlineUser,
|
|
ServerToClientMessage,
|
|
} from "../../../shared/ws.ts";
|
|
import type { Notification } from "../../../shared/notification.ts";
|
|
import {
|
|
deserializeChatMessage,
|
|
deserializeComment,
|
|
deserializeDump,
|
|
deserializeNotification,
|
|
deserializePlaylist,
|
|
deserializePublicUser,
|
|
} from "../../utils/shared/deserialize.ts";
|
|
import { t } from "@lingui/core/macro";
|
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
|
import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts";
|
|
import { playChime } from "../../utils/shared/sound.ts";
|
|
import { mentionsUser } from "../../utils/chat/text.ts";
|
|
|
|
/** Passed to {@link WSProvider} by `App`. */
|
|
export interface WSProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
/** The longest wait between reconnection attempts, in ms. */
|
|
const MAX_BACKOFF = 30_000;
|
|
/** How long an optimistic vote or like waits for the server, in ms. */
|
|
const ACK_TIMEOUT = 5_000;
|
|
/** How long a connection attempt may take before the banner shows, in ms. */
|
|
const CONNECT_TIMEOUT = 2_500;
|
|
/**
|
|
* The most dumps `recentDumps` holds. Older arrivals are in the feeds' later
|
|
* pages.
|
|
*/
|
|
const MAX_RECENT_DUMPS = 100;
|
|
/**
|
|
* The most messages `chatMessages` holds. Older ones are in the history,
|
|
* which `ChatModal` fetches.
|
|
*/
|
|
const MAX_CHAT_MESSAGES = 200;
|
|
/** How long a sent chat message waits for its echo, in ms. */
|
|
const CHAT_SEND_TIMEOUT = 10_000;
|
|
/**
|
|
* The least time between two read markers, in ms. The server drops markers
|
|
* past a rate of one a second.
|
|
*/
|
|
const CHAT_READ_THROTTLE = 1_000;
|
|
/**
|
|
* How close in time a message of the history must be to a failed send with
|
|
* the same text to count as that send, in ms.
|
|
*/
|
|
const CHAT_ECHO_WINDOW = 60_000;
|
|
|
|
/**
|
|
* Lets a failed toggle restore what was shown before the first click the server
|
|
* hasn't acknowledged. {@link useOptimisticToggles} holds one for each such
|
|
* item; `count` and `on` are as they were before that click.
|
|
*/
|
|
interface PendingToggle {
|
|
timeout: ReturnType<typeof setTimeout>;
|
|
count: number;
|
|
on: boolean;
|
|
outstanding: number;
|
|
}
|
|
|
|
/**
|
|
* Returned by {@link useOptimisticToggles}, for the vote and like actions and
|
|
* the socket's handlers.
|
|
*/
|
|
interface OptimisticToggles {
|
|
/** Records a toggle already shown, from the count and state before it. */
|
|
begin: (id: string, count: number, on: boolean) => void;
|
|
/** Settles one toggle; true once none of the item's is left in flight. */
|
|
ack: (id: string) => boolean;
|
|
/** Checks whether the item has toggles in flight. */
|
|
pending: (id: string) => boolean;
|
|
/** Restores the item as it was before its toggles, now. */
|
|
fail: (id: string) => void;
|
|
/**
|
|
* Settles every toggle against the server's set after a (re)connect. Each
|
|
* count moves by one from its baseline when the server's state differs.
|
|
*/
|
|
reconcile: (serverOn: Set<string>) => void;
|
|
/** Forgets every toggle, for a new session. */
|
|
clear: () => void;
|
|
}
|
|
|
|
/**
|
|
* Tracks optimistic toggles of votes or likes, which show at once and go back
|
|
* when the server refuses them or doesn't answer within {@link ACK_TIMEOUT}.
|
|
* Several clicks on one item before any ack share one baseline, so a failure
|
|
* restores what was there before the first, and only the last ack's count is
|
|
* applied.
|
|
*/
|
|
function useOptimisticToggles(
|
|
setOn: React.Dispatch<React.SetStateAction<Set<string>>>,
|
|
setCounts: React.Dispatch<React.SetStateAction<Record<string, number>>>,
|
|
): OptimisticToggles {
|
|
const ref = useRef(new Map<string, PendingToggle>());
|
|
return useMemo(() => {
|
|
const pendingMap = ref.current;
|
|
const restore = (id: string, count: number, on: boolean) => {
|
|
setOn((prev) => {
|
|
const next = new Set(prev);
|
|
if (on) next.add(id);
|
|
else next.delete(id);
|
|
return next;
|
|
});
|
|
setCounts((prev) => ({ ...prev, [id]: count }));
|
|
};
|
|
const fail = (id: string) => {
|
|
const entry = pendingMap.get(id);
|
|
if (!entry) return;
|
|
clearTimeout(entry.timeout);
|
|
pendingMap.delete(id);
|
|
restore(id, entry.count, entry.on);
|
|
};
|
|
return {
|
|
begin(id, count, on) {
|
|
const entry = pendingMap.get(id);
|
|
if (entry) clearTimeout(entry.timeout);
|
|
// Nothing is sent while the socket is down, so the timer fails it.
|
|
pendingMap.set(id, {
|
|
count: entry?.count ?? count,
|
|
on: entry?.on ?? on,
|
|
outstanding: (entry?.outstanding ?? 0) + 1,
|
|
timeout: setTimeout(() => fail(id), ACK_TIMEOUT),
|
|
});
|
|
},
|
|
ack(id) {
|
|
const entry = pendingMap.get(id);
|
|
if (!entry) return true;
|
|
entry.outstanding -= 1;
|
|
if (entry.outstanding > 0) return false;
|
|
clearTimeout(entry.timeout);
|
|
pendingMap.delete(id);
|
|
return true;
|
|
},
|
|
pending: (id) => pendingMap.has(id),
|
|
fail,
|
|
reconcile(serverOn) {
|
|
for (const [id, entry] of pendingMap) {
|
|
clearTimeout(entry.timeout);
|
|
const on = serverOn.has(id);
|
|
const count = entry.count + (on === entry.on ? 0 : on ? 1 : -1);
|
|
setCounts((prev) => ({ ...prev, [id]: Math.max(0, count) }));
|
|
}
|
|
pendingMap.clear();
|
|
},
|
|
clear() {
|
|
for (const entry of pendingMap.values()) clearTimeout(entry.timeout);
|
|
pendingMap.clear();
|
|
},
|
|
};
|
|
}, [setOn, setCounts]);
|
|
}
|
|
|
|
/**
|
|
* Returns `data` as a server message, or null when it isn't JSON with a string
|
|
* `type`. The fields aren't checked: the server is trusted.
|
|
*/
|
|
function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
|
|
try {
|
|
const msg = JSON.parse(data);
|
|
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") {
|
|
return null;
|
|
}
|
|
return msg as Wire<ServerToClientMessage>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext}
|
|
* and {@link AvatarOverrideContext}. It connects with the session's token, or
|
|
* anonymously, and reconnects with a backoff doubling up to 30 s. A new token
|
|
* (sign-in, sign-out, another account) clears the live state and reconnects.
|
|
* A new notification or chat message chimes, and the unread counts go in the
|
|
* tab title.
|
|
*/
|
|
export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|
const { token, user } = useAuth();
|
|
const userId = user?.id ?? null;
|
|
const [wsStatus, setWSStatus] = useState<
|
|
"connecting" | "connected" | "disconnected"
|
|
>("connecting");
|
|
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
|
|
|
// For the reset below, which must follow every state declaration.
|
|
const [prevToken, setPrevToken] = useState(token);
|
|
|
|
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 [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
|
|
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
|
|
PlaylistEvent | null
|
|
>(null);
|
|
const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
|
|
null,
|
|
);
|
|
const [lastUserEvent, setLastUserEvent] = useState<UserEvent | null>(null);
|
|
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
|
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
|
null,
|
|
);
|
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
|
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
|
|
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
|
const [chatReadAt, setChatReadAt] = useState<Date | null>(null);
|
|
const [pendingChatMessages, setPendingChatMessages] = useState<
|
|
PendingChatMessage[]
|
|
>([]);
|
|
const [chatError, setChatError] = useState<ChatError | null>(null);
|
|
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
|
|
const [avatarOverrides, setAvatarOverrides] = useState<
|
|
Record<string, AvatarOverride>
|
|
>({});
|
|
// It is never reset, since pages compare it with the value they last saw.
|
|
const [connectionEpoch, setConnectionEpoch] = useState(0);
|
|
const [followsVersion, setFollowsVersion] = useState(0);
|
|
|
|
// A new token means another session: the `welcome` resyncs votes, likes and
|
|
// unread counts, but what the last session gathered (another account's
|
|
// private dumps, its pending messages) must go. Reset during render, since
|
|
// an effect would render the stale state once more.
|
|
if (prevToken !== token) {
|
|
setPrevToken(token);
|
|
setWSStatus("connecting");
|
|
setWSErrorMessage(null);
|
|
setOnlineUsers([]);
|
|
setVoteCounts({});
|
|
setMyVotes(new Set());
|
|
setLikeCounts({});
|
|
setMyLikes(new Set());
|
|
setRecentDumps([]);
|
|
setDeletedDumpIds(new Set());
|
|
setLastVoteEvent(null);
|
|
setLastDumpEvent(null);
|
|
setLastPlaylistEvent(null);
|
|
setDeletedPlaylistIds(new Set());
|
|
setLastCommentEvent(null);
|
|
setLastUserEvent(null);
|
|
setUnreadNotificationCount(0);
|
|
setLastNotification(null);
|
|
setChatMessages([]);
|
|
setDeletedChatIds(new Set());
|
|
setUnreadChatCount(0);
|
|
setChatReadAt(null);
|
|
setPendingChatMessages([]);
|
|
setChatError(null);
|
|
setTypingUsers([]);
|
|
setAvatarOverrides({});
|
|
}
|
|
|
|
// The socket's handlers and the stable callbacks read the current values
|
|
// through these refs. Depending on the values instead would reconnect the
|
|
// socket, or change the callbacks, on every vote.
|
|
const voteCountsRef = useRef(voteCounts);
|
|
const myVotesRef = useRef(myVotes);
|
|
const likeCountsRef = useRef(likeCounts);
|
|
const myLikesRef = useRef(myLikes);
|
|
const userIdRef = useRef(userId);
|
|
const usernameRef = useRef(user?.username ?? null);
|
|
useLayoutEffect(() => {
|
|
voteCountsRef.current = voteCounts;
|
|
myVotesRef.current = myVotes;
|
|
likeCountsRef.current = likeCounts;
|
|
myLikesRef.current = myLikes;
|
|
userIdRef.current = userId;
|
|
usernameRef.current = user?.username ?? null;
|
|
});
|
|
|
|
const socketRef = useRef<WebSocket | null>(null);
|
|
// A new socket starts with the chat closed on the server's side, so the
|
|
// `welcome` handler announces it again.
|
|
const chatOpenRef = useRef(false);
|
|
// A chat left open in a background tab isn't being read: it must not
|
|
// swallow mention notifications or unread counts.
|
|
const isViewingChat = useCallback(
|
|
() => chatOpenRef.current && !document.hidden,
|
|
[],
|
|
);
|
|
// By nonce. A send is in flight while it has a timer here.
|
|
const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
|
new Map(),
|
|
);
|
|
const chatErrorSeqRef = useRef(0);
|
|
// The read marker, in ms. `Sent` is the newest the server has, from this tab
|
|
// or another. `Queued` is the newest to send, which a trailing timer sends
|
|
// once for a burst of messages.
|
|
const chatReadSentRef = useRef(0);
|
|
const chatReadQueuedRef = useRef(0);
|
|
const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const chatReadLastSendRef = useRef(0);
|
|
|
|
const failPendingChat = useCallback((nonce: string, error: string) => {
|
|
const timer = chatSendTimersRef.current.get(nonce);
|
|
if (timer) clearTimeout(timer);
|
|
chatSendTimersRef.current.delete(nonce);
|
|
setPendingChatMessages((prev) =>
|
|
prev.map((p) => p.nonce === nonce ? { ...p, status: "failed", error } : p)
|
|
);
|
|
}, []);
|
|
|
|
const retirePendingChat = useCallback((nonce: string) => {
|
|
const timer = chatSendTimersRef.current.get(nonce);
|
|
if (timer) clearTimeout(timer);
|
|
chatSendTimersRef.current.delete(nonce);
|
|
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
|
|
}, []);
|
|
|
|
// By dump id, and by comment id for likes. One per item: a second click
|
|
// before the answer replaces the first's timer, so only the latest change
|
|
// can be rolled back.
|
|
const votes = useOptimisticToggles(setMyVotes, setVoteCounts);
|
|
const likes = useOptimisticToggles(setMyLikes, setLikeCounts);
|
|
|
|
useEffect(() => {
|
|
let closed = false;
|
|
let backoff = 500;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let everConnected = false;
|
|
// For this token. Every `welcome` after the first is a reconnect.
|
|
let welcomeCount = 0;
|
|
|
|
function connect() {
|
|
if (closed) return;
|
|
|
|
const url = `${WS_URL}/ws${
|
|
token ? `?token=${encodeURIComponent(token)}` : ""
|
|
}`;
|
|
const ws = new WebSocket(url);
|
|
socketRef.current = ws;
|
|
|
|
// A connection that hangs may take far longer than this to fail. Closing
|
|
// it shows the banner and goes through `onclose` to the next attempt.
|
|
connectTimeout = setTimeout(() => {
|
|
if (ws.readyState !== WebSocket.CONNECTING) return;
|
|
setWSStatus("disconnected");
|
|
setWSErrorMessage(
|
|
t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
|
|
);
|
|
ws.close();
|
|
}, CONNECT_TIMEOUT);
|
|
|
|
ws.onopen = () => {
|
|
if (connectTimeout) {
|
|
clearTimeout(connectTimeout);
|
|
connectTimeout = null;
|
|
}
|
|
everConnected = true;
|
|
setWSStatus("connected");
|
|
setWSErrorMessage(null);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = parseWSMessage(event.data);
|
|
if (!msg) return;
|
|
|
|
switch (msg.type) {
|
|
case "ping":
|
|
// The server closes a socket that misses a pong.
|
|
ws.send(
|
|
JSON.stringify({ type: "pong" } satisfies ClientToServerMessage),
|
|
);
|
|
break;
|
|
|
|
case "welcome":
|
|
backoff = 500;
|
|
setOnlineUsers(msg.users);
|
|
// Toggles sent before a reconnect may or may not have landed: the
|
|
// server's sets say which, and fix the counts shown for them.
|
|
votes.reconcile(new Set(msg.myVotes));
|
|
likes.reconcile(new Set(msg.myCommentLikes));
|
|
setMyVotes(new Set(msg.myVotes));
|
|
setMyLikes(new Set(msg.myCommentLikes));
|
|
setUnreadNotificationCount(msg.unreadNotificationCount);
|
|
setUnreadChatCount(msg.unreadChatCount);
|
|
if (msg.chatReadAt) {
|
|
const readAt = new Date(msg.chatReadAt);
|
|
setChatReadAt(readAt);
|
|
chatReadSentRef.current = Math.max(
|
|
chatReadSentRef.current,
|
|
readAt.getTime(),
|
|
);
|
|
}
|
|
welcomeCount += 1;
|
|
if (welcomeCount > 1) {
|
|
setConnectionEpoch((n) => n + 1);
|
|
// With the chat closed, nothing resyncs the live buffer, and its
|
|
// stale copies would win over the history fetched on open.
|
|
if (!chatOpenRef.current) {
|
|
setChatMessages([]);
|
|
setDeletedChatIds(new Set());
|
|
}
|
|
}
|
|
// A marker set while offline goes out now.
|
|
if (chatReadQueuedRef.current > chatReadSentRef.current) {
|
|
ws.send(
|
|
JSON.stringify(
|
|
{
|
|
type: "chat_read",
|
|
at: new Date(chatReadQueuedRef.current).toISOString(),
|
|
} satisfies ClientToServerMessage,
|
|
),
|
|
);
|
|
chatReadSentRef.current = chatReadQueuedRef.current;
|
|
}
|
|
// The server knows the chat is open per socket, and this one is
|
|
// new.
|
|
if (isViewingChat()) {
|
|
ws.send(
|
|
JSON.stringify(
|
|
{
|
|
type: "chat_focus",
|
|
open: true,
|
|
} satisfies ClientToServerMessage,
|
|
),
|
|
);
|
|
}
|
|
break;
|
|
|
|
case "presence_update":
|
|
setOnlineUsers(msg.users);
|
|
break;
|
|
|
|
case "votes_update": {
|
|
const { dumpId, voteCount, voterId, action } = msg;
|
|
setLastVoteEvent({ dumpId, voterId, action });
|
|
// While this tab has votes on it in flight, their acks settle it.
|
|
if (votes.pending(dumpId)) break;
|
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
|
// The user's own vote from another tab keeps this tab's button in
|
|
// step.
|
|
if (voterId === userIdRef.current) {
|
|
setMyVotes((prev) => {
|
|
const next = new Set(prev);
|
|
if (action === "cast") next.add(dumpId);
|
|
else next.delete(dumpId);
|
|
return next;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "comment_likes_update": {
|
|
const { commentId, likeCount, likerId } = msg;
|
|
// As for votes.
|
|
if (likes.pending(commentId)) break;
|
|
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
|
if (likerId === userIdRef.current) {
|
|
const { action } = msg;
|
|
setMyLikes((prev) => {
|
|
const next = new Set(prev);
|
|
if (action === "cast") next.add(commentId);
|
|
else next.delete(commentId);
|
|
return next;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "dump_created": {
|
|
// Also sent when a dump goes public, which to everyone else is an
|
|
// arrival, and may undo its `dump_deleted` from going private.
|
|
const dump = deserializeDump(msg.dump);
|
|
setDeletedDumpIds((prev) => {
|
|
if (!prev.has(dump.id)) return prev;
|
|
const next = new Set(prev);
|
|
next.delete(dump.id);
|
|
return next;
|
|
});
|
|
setRecentDumps((prev) =>
|
|
[dump, ...prev.filter((d) => d.id !== dump.id)].slice(
|
|
0,
|
|
MAX_RECENT_DUMPS,
|
|
)
|
|
);
|
|
// The profiles put a dump back where it was when it turns public.
|
|
setLastDumpEvent(dump);
|
|
break;
|
|
}
|
|
|
|
case "dump_updated": {
|
|
const dump = deserializeDump(msg.dump);
|
|
setLastDumpEvent(dump);
|
|
// An edit is not an arrival: it must not pull an old dump to the
|
|
// top of the feeds.
|
|
setRecentDumps((prev) =>
|
|
prev.some((d) => d.id === dump.id)
|
|
? prev.map((d) => d.id === dump.id ? dump : d)
|
|
: prev
|
|
);
|
|
break;
|
|
}
|
|
|
|
case "dump_deleted": {
|
|
// Also sent to others when someone's dump goes private.
|
|
const { dumpId } = msg;
|
|
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
|
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
|
break;
|
|
}
|
|
|
|
case "vote_ack": {
|
|
const { dumpId, action, voteCount } = msg;
|
|
// With more clicks in flight, the last ack tells how it ended.
|
|
if (!votes.ack(dumpId)) break;
|
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
|
setMyVotes((prev) => {
|
|
const next = new Set(prev);
|
|
if (action === "cast") next.add(dumpId);
|
|
else next.delete(dumpId);
|
|
return next;
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "comment_like_ack": {
|
|
const { commentId, action, likeCount } = msg;
|
|
if (!likes.ack(commentId)) break;
|
|
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
|
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);
|
|
setLastPlaylistEvent({
|
|
type: msg.type === "playlist_created" ? "created" : "updated",
|
|
playlistId: playlist.id,
|
|
playlist,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "playlist_deleted": {
|
|
const { playlistId, userId } = msg;
|
|
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
|
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
|
|
break;
|
|
}
|
|
|
|
case "playlist_dumps_updated": {
|
|
const { playlistId, dumpIds, dumpCount } = msg;
|
|
setLastPlaylistEvent({
|
|
type: "dumps_updated",
|
|
playlistId,
|
|
dumpIds,
|
|
dumpCount,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "user_updated": {
|
|
const user = deserializePublicUser(msg.user);
|
|
setLastUserEvent({ user });
|
|
setAvatarOverrides((prev) => ({
|
|
...prev,
|
|
[user.id]: {
|
|
hasAvatar: !!user.avatarMime,
|
|
version: user.updatedAt?.getTime() ?? Date.now(),
|
|
},
|
|
}));
|
|
break;
|
|
}
|
|
|
|
case "comment_created": {
|
|
const comment = deserializeComment(msg.comment);
|
|
setLastCommentEvent({
|
|
type: "created",
|
|
dumpId: comment.dumpId,
|
|
comment,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "comment_deleted": {
|
|
const { commentId, dumpId } = msg;
|
|
setLastCommentEvent({ type: "deleted", dumpId, commentId });
|
|
break;
|
|
}
|
|
|
|
case "comment_updated": {
|
|
const comment = deserializeComment(msg.comment);
|
|
setLastCommentEvent({
|
|
type: "updated",
|
|
dumpId: comment.dumpId,
|
|
comment,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "notification_created": {
|
|
const notification = deserializeNotification(msg.notification);
|
|
setLastNotification(notification);
|
|
setUnreadNotificationCount((prev) => prev + 1);
|
|
playChime("notify", notification.id);
|
|
break;
|
|
}
|
|
|
|
case "chat_message": {
|
|
const message = deserializeChatMessage(msg.message);
|
|
setChatMessages((prev) => {
|
|
if (prev.some((m) => m.id === message.id)) return prev;
|
|
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
|
|
});
|
|
// The sender's own echo: its pending copy is now a message.
|
|
if (msg.nonce) retirePendingChat(msg.nonce);
|
|
// A message read live isn't counted: the chat moves the marker.
|
|
if (message.userId !== userIdRef.current) {
|
|
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
|
|
const username = usernameRef.current;
|
|
if (
|
|
message.mentionsEveryone ||
|
|
(username && mentionsUser(message.body, username))
|
|
) {
|
|
// The server sends no notification for a mention read live, so
|
|
// the chime comes from here. Otherwise the notification that
|
|
// follows chimes.
|
|
if (isViewingChat()) playChime("notify", message.id);
|
|
} else if (!isViewingChat()) {
|
|
playChime("chat", message.id);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "chat_message_updated": {
|
|
const message = deserializeChatMessage(msg.message);
|
|
// An edit of a message older than the buffer is added to it, so
|
|
// it wins over the history's copy. `ChatModal` sorts by time.
|
|
setChatMessages((prev) => {
|
|
const idx = prev.findIndex((m) => m.id === message.id);
|
|
if (idx === -1) return [...prev, message];
|
|
const next = [...prev];
|
|
next[idx] = message;
|
|
return next;
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "chat_message_deleted": {
|
|
const { id } = msg;
|
|
setDeletedChatIds((prev) => new Set(prev).add(id));
|
|
setChatMessages((prev) => prev.filter((m) => m.id !== id));
|
|
break;
|
|
}
|
|
|
|
case "chat_typing_update":
|
|
setTypingUsers(msg.users);
|
|
break;
|
|
|
|
case "follows_changed":
|
|
setFollowsVersion((n) => n + 1);
|
|
break;
|
|
|
|
case "chat_read_update": {
|
|
// Sent to every socket of the user, whichever tab moved it.
|
|
const readAt = new Date(msg.readAt);
|
|
chatReadSentRef.current = Math.max(
|
|
chatReadSentRef.current,
|
|
readAt.getTime(),
|
|
);
|
|
setChatReadAt(readAt);
|
|
setUnreadChatCount(msg.unread);
|
|
break;
|
|
}
|
|
|
|
case "error": {
|
|
if (msg.scope === "chat") {
|
|
const message = msg.message ?? t`Something went wrong`;
|
|
if (msg.nonce) failPendingChat(msg.nonce, message);
|
|
else setChatError({ id: ++chatErrorSeqRef.current, message });
|
|
break;
|
|
}
|
|
// A failed vote or like names its dump or comment, so it is
|
|
// rolled back at once. Other errors leave it to the timer.
|
|
if (msg.dumpId) votes.fail(msg.dumpId);
|
|
else if (msg.commentId) likes.fail(msg.commentId);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (connectTimeout) {
|
|
clearTimeout(connectTimeout);
|
|
connectTimeout = null;
|
|
}
|
|
if (closed) return;
|
|
// Sends still waiting won't get their echo on this socket. They may
|
|
// have landed, though: `reconcileChat` retires those it finds.
|
|
for (const nonce of chatSendTimersRef.current.keys()) {
|
|
failPendingChat(
|
|
nonce,
|
|
t`Connection lost — this message may not have been sent`,
|
|
);
|
|
}
|
|
setWSStatus("disconnected");
|
|
setWSErrorMessage(
|
|
everConnected
|
|
? t`Live updates are temporarily disconnected. Trying to reconnect…`
|
|
: t`Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.`,
|
|
);
|
|
reconnectTimer = setTimeout(() => {
|
|
backoff = Math.min(backoff * 2, MAX_BACKOFF);
|
|
connect();
|
|
}, backoff);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
// `onclose` follows, and reconnects.
|
|
};
|
|
}
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
closed = true;
|
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
if (connectTimeout) clearTimeout(connectTimeout);
|
|
socketRef.current?.close();
|
|
socketRef.current = null;
|
|
votes.clear();
|
|
likes.clear();
|
|
};
|
|
}, [
|
|
votes,
|
|
likes,
|
|
failPendingChat,
|
|
isViewingChat,
|
|
retirePendingChat,
|
|
token,
|
|
]);
|
|
|
|
/**
|
|
* Shows a vote or like toggled at once, records it with `toggles`, and sends
|
|
* `type` for it. `on` is the state wanted; a click that asks for the state
|
|
* already shown does nothing.
|
|
*/
|
|
const toggle = useCallback((
|
|
toggles: OptimisticToggles,
|
|
onRef: React.RefObject<Set<string>>,
|
|
countsRef: React.RefObject<Record<string, number>>,
|
|
setOn: React.Dispatch<React.SetStateAction<Set<string>>>,
|
|
setCounts: React.Dispatch<React.SetStateAction<Record<string, number>>>,
|
|
id: string,
|
|
on: boolean,
|
|
shownCount: number | undefined,
|
|
message: ClientToServerMessage,
|
|
) => {
|
|
// Until the socket brings a count, the one on screen is the only one.
|
|
const prevCount = countsRef.current[id] ?? shownCount ?? 0;
|
|
const prevOn = onRef.current.has(id);
|
|
if (prevOn === on) return;
|
|
setOn((prev) => {
|
|
const next = new Set(prev);
|
|
if (on) next.add(id);
|
|
else next.delete(id);
|
|
return next;
|
|
});
|
|
setCounts((prev) => ({
|
|
...prev,
|
|
[id]: Math.max(0, prevCount + (on ? 1 : -1)),
|
|
}));
|
|
toggles.begin(id, prevCount, prevOn);
|
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
|
socketRef.current.send(JSON.stringify(message));
|
|
}
|
|
}, []);
|
|
|
|
const castVote = useCallback(
|
|
(dumpId: string, shownCount?: number) =>
|
|
toggle(
|
|
votes,
|
|
myVotesRef,
|
|
voteCountsRef,
|
|
setMyVotes,
|
|
setVoteCounts,
|
|
dumpId,
|
|
true,
|
|
shownCount,
|
|
{ type: "vote_cast", dumpId },
|
|
),
|
|
[toggle, votes],
|
|
);
|
|
|
|
const removeVote = useCallback(
|
|
(dumpId: string, shownCount?: number) =>
|
|
toggle(
|
|
votes,
|
|
myVotesRef,
|
|
voteCountsRef,
|
|
setMyVotes,
|
|
setVoteCounts,
|
|
dumpId,
|
|
false,
|
|
shownCount,
|
|
{ type: "vote_remove", dumpId },
|
|
),
|
|
[toggle, votes],
|
|
);
|
|
|
|
const castCommentLike = useCallback(
|
|
(commentId: string, shownCount?: number) =>
|
|
toggle(
|
|
likes,
|
|
myLikesRef,
|
|
likeCountsRef,
|
|
setMyLikes,
|
|
setLikeCounts,
|
|
commentId,
|
|
true,
|
|
shownCount,
|
|
{ type: "comment_like_cast", commentId },
|
|
),
|
|
[toggle, likes],
|
|
);
|
|
|
|
const removeCommentLike = useCallback(
|
|
(commentId: string, shownCount?: number) =>
|
|
toggle(
|
|
likes,
|
|
myLikesRef,
|
|
likeCountsRef,
|
|
setMyLikes,
|
|
setLikeCounts,
|
|
commentId,
|
|
false,
|
|
shownCount,
|
|
{ type: "comment_like_remove", commentId },
|
|
),
|
|
[toggle, likes],
|
|
);
|
|
|
|
const injectDump = useCallback((dump: Dump) => {
|
|
setRecentDumps((prev) => {
|
|
if (prev.some((d) => d.id === dump.id)) return prev;
|
|
return [dump, ...prev].slice(0, MAX_RECENT_DUMPS);
|
|
});
|
|
}, []);
|
|
|
|
const clearUnreadNotifications = useCallback(() => {
|
|
setUnreadNotificationCount(0);
|
|
}, []);
|
|
|
|
// The pending message leaves the list with its echo (`chat_message`), or
|
|
// fails on a refusal (`error`), on a closed socket or after the timeout.
|
|
const transmitPendingChat = useCallback((pending: PendingChatMessage) => {
|
|
const socket = socketRef.current;
|
|
if (socket?.readyState !== WebSocket.OPEN) {
|
|
failPendingChat(pending.nonce, t`You're offline — not sent`);
|
|
return;
|
|
}
|
|
socket.send(
|
|
JSON.stringify(
|
|
{
|
|
type: "chat_send",
|
|
body: pending.body,
|
|
nonce: pending.nonce,
|
|
...(pending.replyTo ? { replyToId: pending.replyTo.id } : {}),
|
|
} satisfies ClientToServerMessage,
|
|
),
|
|
);
|
|
chatSendTimersRef.current.set(
|
|
pending.nonce,
|
|
setTimeout(
|
|
() =>
|
|
failPendingChat(
|
|
pending.nonce,
|
|
t`No reply from the server — this message may not have been sent`,
|
|
),
|
|
CHAT_SEND_TIMEOUT,
|
|
),
|
|
);
|
|
}, [failPendingChat]);
|
|
|
|
const sendChatMessage = useCallback(
|
|
(body: string, replyTo?: ChatReplyTarget) => {
|
|
const trimmed = body.trim();
|
|
if (!trimmed) return;
|
|
const pending: PendingChatMessage = {
|
|
nonce: crypto.randomUUID(),
|
|
body: trimmed,
|
|
replyTo,
|
|
createdAt: new Date(),
|
|
status: "sending",
|
|
};
|
|
setPendingChatMessages((prev) => [...prev, pending]);
|
|
transmitPendingChat(pending);
|
|
},
|
|
[transmitPendingChat],
|
|
);
|
|
|
|
const pendingChatRef = useRef(pendingChatMessages);
|
|
useLayoutEffect(() => {
|
|
pendingChatRef.current = pendingChatMessages;
|
|
});
|
|
|
|
const retryChatMessage = useCallback((nonce: string) => {
|
|
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
|
|
if (!pending) return;
|
|
// A late echo of the first attempt must not retire the retry.
|
|
const retry: PendingChatMessage = {
|
|
...pending,
|
|
nonce: crypto.randomUUID(),
|
|
status: "sending",
|
|
error: undefined,
|
|
};
|
|
setPendingChatMessages((prev) =>
|
|
prev.map((p) => (p.nonce === nonce ? retry : p))
|
|
);
|
|
transmitPendingChat(retry);
|
|
}, [transmitPendingChat]);
|
|
|
|
const discardChatMessage = useCallback((nonce: string) => {
|
|
retirePendingChat(nonce);
|
|
}, [retirePendingChat]);
|
|
|
|
const editChatMessage = useCallback((id: string, body: string) => {
|
|
const trimmed = body.trim();
|
|
if (!trimmed) return;
|
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
|
socketRef.current.send(
|
|
JSON.stringify(
|
|
{
|
|
type: "chat_edit",
|
|
id,
|
|
body: trimmed,
|
|
} satisfies ClientToServerMessage,
|
|
),
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
const deleteChatMessage = useCallback((id: string) => {
|
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
|
socketRef.current.send(
|
|
JSON.stringify(
|
|
{ type: "chat_delete", id } satisfies ClientToServerMessage,
|
|
),
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
const dismissChatError = useCallback(() => setChatError(null), []);
|
|
|
|
const flushChatRead = useCallback(() => {
|
|
chatReadTimerRef.current = null;
|
|
const at = chatReadQueuedRef.current;
|
|
if (at <= chatReadSentRef.current) return;
|
|
const socket = socketRef.current;
|
|
// Offline, it stays queued, and the next `welcome` sends it.
|
|
if (socket?.readyState !== WebSocket.OPEN) return;
|
|
socket.send(
|
|
JSON.stringify(
|
|
{
|
|
type: "chat_read",
|
|
at: new Date(at).toISOString(),
|
|
} satisfies ClientToServerMessage,
|
|
),
|
|
);
|
|
chatReadSentRef.current = at;
|
|
chatReadLastSendRef.current = Date.now();
|
|
}, []);
|
|
|
|
const markChatRead = useCallback((at: Date) => {
|
|
const time = at.getTime();
|
|
if (time <= Math.max(chatReadSentRef.current, chatReadQueuedRef.current)) {
|
|
return;
|
|
}
|
|
chatReadQueuedRef.current = time;
|
|
// The badge goes at once. The server's count follows the marker.
|
|
setUnreadChatCount(0);
|
|
if (chatReadTimerRef.current) return;
|
|
const wait = Math.max(
|
|
0,
|
|
CHAT_READ_THROTTLE - (Date.now() - chatReadLastSendRef.current),
|
|
);
|
|
chatReadTimerRef.current = setTimeout(flushChatRead, wait);
|
|
}, [flushChatRead]);
|
|
|
|
useEffect(() => () => {
|
|
if (chatReadTimerRef.current) clearTimeout(chatReadTimerRef.current);
|
|
for (const timer of chatSendTimersRef.current.values()) clearTimeout(timer);
|
|
}, []);
|
|
|
|
const reconcileChat = useCallback(
|
|
(messages: ChatMessage[], from: Date, to: Date) => {
|
|
// A live copy of a message edited while the socket was down is stale,
|
|
// and `ChatModal`'s merge would let it win over the fetched one.
|
|
const byId = new Map(messages.map((m) => [m.id, m]));
|
|
setChatMessages((prev) =>
|
|
prev
|
|
.filter((m) =>
|
|
byId.has(m.id) || m.createdAt < from || m.createdAt > to
|
|
)
|
|
.map((m) => byId.get(m.id) ?? m)
|
|
);
|
|
// A failed send that did land lost only its echo. Its text and time
|
|
// are all there is to recognize it by.
|
|
const mine = messages.filter((m) => m.userId === userIdRef.current);
|
|
for (const pending of pendingChatRef.current) {
|
|
if (pending.status !== "failed") continue;
|
|
const landed = mine.some((m) =>
|
|
m.body === pending.body &&
|
|
Math.abs(m.createdAt.getTime() - pending.createdAt.getTime()) <
|
|
CHAT_ECHO_WINDOW
|
|
);
|
|
if (landed) retirePendingChat(pending.nonce);
|
|
}
|
|
},
|
|
[retirePendingChat],
|
|
);
|
|
|
|
const setChatTyping = useCallback((typing: boolean) => {
|
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
|
socketRef.current.send(
|
|
JSON.stringify(
|
|
{ type: "chat_typing", typing } satisfies ClientToServerMessage,
|
|
),
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
const sendChatFocus = useCallback(() => {
|
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
|
socketRef.current.send(
|
|
JSON.stringify(
|
|
{
|
|
type: "chat_focus",
|
|
open: isViewingChat(),
|
|
} satisfies ClientToServerMessage,
|
|
),
|
|
);
|
|
}
|
|
}, [isViewingChat]);
|
|
|
|
const setChatOpen = useCallback((open: boolean) => {
|
|
chatOpenRef.current = open;
|
|
sendChatFocus();
|
|
}, [sendChatFocus]);
|
|
|
|
// Hiding the tab stops the user reading an open chat, and showing it again
|
|
// resumes.
|
|
useEffect(() => {
|
|
const onVisibility = () => {
|
|
if (chatOpenRef.current) sendChatFocus();
|
|
};
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
return () => document.removeEventListener("visibilitychange", onVisibility);
|
|
}, [sendChatFocus]);
|
|
|
|
useEffect(() => {
|
|
setTitleBadge(unreadNotificationCount + unreadChatCount);
|
|
}, [unreadNotificationCount, unreadChatCount]);
|
|
|
|
const value: WSContextValue = useMemo(() => ({
|
|
wsStatus,
|
|
wsErrorMessage,
|
|
voteCounts,
|
|
myVotes,
|
|
likeCounts,
|
|
myLikes,
|
|
recentDumps,
|
|
deletedDumpIds,
|
|
lastVoteEvent,
|
|
lastDumpEvent,
|
|
lastPlaylistEvent,
|
|
deletedPlaylistIds,
|
|
lastCommentEvent,
|
|
lastUserEvent,
|
|
unreadNotificationCount,
|
|
lastNotification,
|
|
connectionEpoch,
|
|
followsVersion,
|
|
castVote,
|
|
removeVote,
|
|
castCommentLike,
|
|
removeCommentLike,
|
|
injectDump,
|
|
clearUnreadNotifications,
|
|
}), [
|
|
wsStatus,
|
|
wsErrorMessage,
|
|
voteCounts,
|
|
myVotes,
|
|
likeCounts,
|
|
myLikes,
|
|
recentDumps,
|
|
deletedDumpIds,
|
|
lastVoteEvent,
|
|
lastDumpEvent,
|
|
lastPlaylistEvent,
|
|
deletedPlaylistIds,
|
|
lastCommentEvent,
|
|
lastUserEvent,
|
|
unreadNotificationCount,
|
|
lastNotification,
|
|
connectionEpoch,
|
|
followsVersion,
|
|
castVote,
|
|
removeVote,
|
|
castCommentLike,
|
|
removeCommentLike,
|
|
injectDump,
|
|
clearUnreadNotifications,
|
|
]);
|
|
|
|
const chatValue: WSChatContextValue = useMemo(() => ({
|
|
chatMessages,
|
|
deletedChatIds,
|
|
unreadChatCount,
|
|
chatReadAt,
|
|
pendingChatMessages,
|
|
chatError,
|
|
typingUsers,
|
|
sendChatMessage,
|
|
retryChatMessage,
|
|
discardChatMessage,
|
|
editChatMessage,
|
|
deleteChatMessage,
|
|
dismissChatError,
|
|
markChatRead,
|
|
reconcileChat,
|
|
setChatTyping,
|
|
setChatOpen,
|
|
}), [
|
|
chatMessages,
|
|
deletedChatIds,
|
|
unreadChatCount,
|
|
chatReadAt,
|
|
pendingChatMessages,
|
|
chatError,
|
|
typingUsers,
|
|
sendChatMessage,
|
|
retryChatMessage,
|
|
discardChatMessage,
|
|
editChatMessage,
|
|
deleteChatMessage,
|
|
dismissChatError,
|
|
markChatRead,
|
|
reconcileChat,
|
|
setChatTyping,
|
|
setChatOpen,
|
|
]);
|
|
|
|
const presenceValue: WSPresenceContextValue = useMemo(
|
|
() => ({ onlineUsers }),
|
|
[onlineUsers],
|
|
);
|
|
|
|
return (
|
|
<WSContext.Provider value={value}>
|
|
<WSChatContext.Provider value={chatValue}>
|
|
<WSPresenceContext.Provider value={presenceValue}>
|
|
<AvatarOverrideContext.Provider value={avatarOverrides}>
|
|
{children}
|
|
</AvatarOverrideContext.Provider>
|
|
</WSPresenceContext.Provider>
|
|
</WSChatContext.Provider>
|
|
</WSContext.Provider>
|
|
);
|
|
}
|