- An action in an encounter no longer loads the encounter again: the answer to the action already holds it. Live updates that arrive together are answered with one reload, of the encounter or character that changed, where each used to reload every one on screen. - Everything the site sends is compressed. Nothing in front of it did that (frp only tunnels), so the API and the built site now do it themselves: the main script is 100 kB on the wire, down from 330 kB. - Each page is its own download, fetched the first time it is opened, so signing in does not wait on the encounter tracker or the builder. - The library is fetched when one of its pickers is opened, where the campaign page used to fetch it on every visit by the DM. - The database has indexes on the columns every list is looked up by, and checking that someone is in a campaign takes one query, not two. - On a server: "npm install", then "npm run deploy", then build the frontend again and restart both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
|
import { io, type Socket } from 'socket.io-client';
|
|
import { API_URL } from '../api/client';
|
|
import { useAuth } from '../auth/useAuth';
|
|
|
|
let sharedSocket: Socket | null = null;
|
|
|
|
function getSocket(token: string): Socket {
|
|
if (!sharedSocket) {
|
|
sharedSocket = io(API_URL, { auth: { token }, transports: ['websocket', 'polling'] });
|
|
}
|
|
return sharedSocket;
|
|
}
|
|
|
|
// One change on the server often sends two or three events together. They are
|
|
// gathered for this long and answered with one refetch per query.
|
|
const FLUSH_MS = 50;
|
|
|
|
// Joins the Socket.io room for a campaign and keeps React Query's cache in
|
|
// sync with server-pushed events (HP/condition changes, encounter rounds,
|
|
// new characters) so every open tab in the campaign updates live.
|
|
export function useCampaignSocket(campaignId: string | undefined) {
|
|
const { token } = useAuth();
|
|
const qc = useQueryClient();
|
|
|
|
useEffect(() => {
|
|
if (!campaignId || !token) return;
|
|
const socket = getSocket(token);
|
|
socket.emit('campaign:join', campaignId);
|
|
|
|
const pending = new Map<string, QueryKey>();
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const flush = () => {
|
|
timer = undefined;
|
|
const keys = [...pending.values()];
|
|
pending.clear();
|
|
// A wider key covers a narrower one waiting beside it: ['encounter'] takes in ['encounter', id].
|
|
const covered = (key: QueryKey) => keys.some((other) => other.length < key.length && other.every((part, i) => part === key[i]));
|
|
for (const queryKey of keys) if (!covered(queryKey)) qc.invalidateQueries({ queryKey });
|
|
};
|
|
const invalidate = (queryKey: QueryKey) => {
|
|
pending.set(JSON.stringify(queryKey), queryKey);
|
|
timer ??= setTimeout(flush, FLUSH_MS);
|
|
};
|
|
// An event names what changed when it is one thing, and nothing when it may be many.
|
|
const one = (root: string, id?: string) => invalidate(id ? [root, id] : [root]);
|
|
|
|
const onCharacterUpdated = ({ characterId }: { characterId?: string } = {}) => {
|
|
invalidate(['campaign', campaignId, 'characters']);
|
|
one('character', characterId);
|
|
invalidate(['encounter']); // a character combatant shows the sheet HP
|
|
// A character arriving somewhere new adds it to the places their player can see.
|
|
invalidate(['campaign', campaignId, 'locations']);
|
|
};
|
|
const onCharacterCreated = () => {
|
|
invalidate(['campaign', campaignId, 'characters']);
|
|
};
|
|
const onParticipantUpdated = ({ encounterId }: { encounterId?: string } = {}) => {
|
|
one('encounter', encounterId);
|
|
// A player's list only holds the encounters their characters are in.
|
|
invalidate(['campaign', campaignId, 'encounters']);
|
|
};
|
|
const onRoundAdvanced = ({ encounterId }: { encounterId?: string } = {}) => {
|
|
one('encounter', encounterId);
|
|
};
|
|
const onCampaignDeleted = () => {
|
|
invalidate(['campaigns']);
|
|
invalidate(['campaign', campaignId]);
|
|
};
|
|
|
|
socket.on('character:updated', onCharacterUpdated);
|
|
socket.on('character:created', onCharacterCreated);
|
|
socket.on('encounter:participant-updated', onParticipantUpdated);
|
|
socket.on('encounter:round-advanced', onRoundAdvanced);
|
|
socket.on('campaign:deleted', onCampaignDeleted);
|
|
|
|
return () => {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
flush();
|
|
}
|
|
socket.emit('campaign:leave', campaignId);
|
|
socket.off('character:updated', onCharacterUpdated);
|
|
socket.off('character:created', onCharacterCreated);
|
|
socket.off('encounter:participant-updated', onParticipantUpdated);
|
|
socket.off('encounter:round-advanced', onRoundAdvanced);
|
|
socket.off('campaign:deleted', onCampaignDeleted);
|
|
};
|
|
}, [campaignId, token, qc]);
|
|
}
|