diff --git a/app/features/chat/ChatProvider.tsx b/app/features/chat/ChatProvider.tsx index 3e76874ef..c11dac5b8 100644 --- a/app/features/chat/ChatProvider.tsx +++ b/app/features/chat/ChatProvider.tsx @@ -277,7 +277,11 @@ function ChatProviderInner({ isSystemMessage, ); if (isSystemMessage || messageArr[0].revalidateOnly) { - revalidate(); + // The actor that triggered this revalidate is the current user — their + // own form submission already reran loaders, so skip the duplicate fetch. + const isOwnRevalidate = + messageArr[0].revalidateOnly && messageArr[0].authorUserId === userId; + if (!isOwnRevalidate) revalidate(); } const sound = messageTypeToSound(messageArr[0].type); @@ -444,6 +448,14 @@ function ChatProviderInner({ ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE", chatCode })); }, []); + const subscribeTopic = React.useCallback((topic: string) => { + ws.current?.send(JSON.stringify({ event: "SUBSCRIBE_TOPIC", topic })); + }, []); + + const unsubscribeTopic = React.useCallback((topic: string) => { + ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE_TOPIC", topic })); + }, []); + const requestHistory = React.useCallback((chatCode: string) => { ws.current?.send(JSON.stringify({ event: "CHAT_HISTORY", chatCode })); }, []); @@ -508,6 +520,8 @@ function ChatProviderInner({ send, subscribe, unsubscribe, + subscribeTopic, + unsubscribeTopic, requestHistory, markAsRead, unreadCounts, @@ -529,6 +543,8 @@ function ChatProviderInner({ send, subscribe, unsubscribe, + subscribeTopic, + unsubscribeTopic, requestHistory, markAsRead, unreadCounts, diff --git a/app/features/chat/ChatSystemMessage.server.ts b/app/features/chat/ChatSystemMessage.server.ts index 69da91c06..8fa6bc90d 100644 --- a/app/features/chat/ChatSystemMessage.server.ts +++ b/app/features/chat/ChatSystemMessage.server.ts @@ -25,7 +25,7 @@ function logSkalpError(action: string) { type PartialChatMessage = Pick< ChatMessage, - "type" | "context" | "room" | "revalidateOnly" + "type" | "context" | "room" | "revalidateOnly" | "authorUserId" >; interface ChatSystemMessageService { send: (msg: PartialChatMessage | PartialChatMessage[]) => undefined; @@ -59,6 +59,7 @@ export const send: ChatSystemMessageService["send"] = (partialMsg) => { context: partialMsg.context, type: partialMsg.type, revalidateOnly: partialMsg.revalidateOnly, + authorUserId: partialMsg.authorUserId, }; }); diff --git a/app/features/chat/chat-hooks.ts b/app/features/chat/chat-hooks.ts index 51a2ee265..57d3ecebd 100644 --- a/app/features/chat/chat-hooks.ts +++ b/app/features/chat/chat-hooks.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useUser } from "../auth/core/user"; import type { ChatMessage } from "./chat-types"; +import { useChatContext } from "./useChatContext"; // increasing this = scrolling happens even when scrolled more upwards const THRESHOLD = 100; @@ -69,3 +70,25 @@ export function useChatAutoScroll( scrollToBottom, }; } + +/** + * Subscribes the page to a Skalop topic over the shared chat WebSocket so that + * `revalidateOnly` broadcasts to the topic trigger a data loader revalidation. + * Topics are lightweight: no metadata, no participants, no history — purely a + * fan-out channel. Pass `connected=false` to opt out (e.g. once a tournament + * has been finalized and no further updates are expected). + */ +export function useWebsocketRevalidation(topic: string, connected = true) { + const chat = useChatContext(); + const subscribeTopic = chat?.subscribeTopic; + const unsubscribeTopic = chat?.unsubscribeTopic; + const readyState = chat?.readyState; + + React.useEffect(() => { + if (!connected || readyState !== "CONNECTED") return; + if (!subscribeTopic || !unsubscribeTopic) return; + + subscribeTopic(topic); + return () => unsubscribeTopic(topic); + }, [topic, connected, readyState, subscribeTopic, unsubscribeTopic]); +} diff --git a/app/features/chat/chat-provider-types.ts b/app/features/chat/chat-provider-types.ts index 4d1162278..e0d6476e2 100644 --- a/app/features/chat/chat-provider-types.ts +++ b/app/features/chat/chat-provider-types.ts @@ -42,6 +42,8 @@ export interface ChatContextValue { send: (chatCode: string, contents: string) => void; subscribe: (chatCode: string) => void; unsubscribe: (chatCode: string) => void; + subscribeTopic: (topic: string) => void; + unsubscribeTopic: (topic: string) => void; requestHistory: (chatCode: string) => void; markAsRead: (chatCode: string) => void; unreadCounts: Record; diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 6a535db07..8fe14dee9 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -23,6 +23,8 @@ export interface ChatMessage { context?: SystemMessageContext; /** If true, the purpose of this message is just to run the data loaders again meaning the logic related to showing a new chat message is skipped. Defaults to false. */ revalidateOnly?: boolean; + /** User id of the actor that triggered this message. Used to skip own-author revalidates so we don't double-fetch loaders right after a form submission. */ + authorUserId?: number; userId?: number; timestamp: number; room: string; diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 69d4fa7b4..1af5e95f1 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -102,6 +102,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ChatSystemMessage.send({ room: match.chatCode, revalidateOnly: true, + authorUserId: user.id, }); } } @@ -154,6 +155,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ChatSystemMessage.send({ room: match.chatCode, revalidateOnly: true, + authorUserId: user.id, }); } @@ -226,6 +228,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ChatSystemMessage.send({ room: match.chatCode, revalidateOnly: true, + authorUserId: user.id, }); } @@ -280,6 +283,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ChatSystemMessage.send({ room: match.chatCode, revalidateOnly: true, + authorUserId: user.id, }); } @@ -304,6 +308,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ChatSystemMessage.send({ room: match.chatCode, revalidateOnly: true, + authorUserId: user.id, }); } @@ -384,6 +389,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ChatSystemMessage.send({ room: match.chatCode, revalidateOnly: true, + authorUserId: user.id, }); } diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index e6837b014..c539d42b1 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -57,6 +57,7 @@ export const action: ActionFunction = async ({ request }) => { room: targetChatCode, type: "LIKE_RECEIVED", revalidateOnly: true, + authorUserId: user.id, }); } @@ -78,6 +79,7 @@ export const action: ActionFunction = async ({ request }) => { room: targetChatCode, type: "LIKE_RECEIVED", revalidateOnly: true, + authorUserId: user.id, }); } break; @@ -201,11 +203,13 @@ export const action: ActionFunction = async ({ request }) => { room: ownGroup.chatCode, type: "MATCH_STARTED", revalidateOnly: true, + authorUserId: user.id, }, { room: theirGroup.chatCode, type: "MATCH_STARTED", revalidateOnly: true, + authorUserId: user.id, }, ]); } diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts index 44fc1391e..989da7574 100644 --- a/app/features/sendouq/actions/q.preparing.server.ts +++ b/app/features/sendouq/actions/q.preparing.server.ts @@ -66,6 +66,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { ChatSystemMessage.send({ room: chatCodeToRevalidate, revalidateOnly: true, + authorUserId: user.id, }); } diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index 0e83c5565..58f52b851 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -41,6 +41,7 @@ export const action: ActionFunction = async ({ request }) => { ChatSystemMessage.send({ room: chatCodeToRevalidate, revalidateOnly: true, + authorUserId: user.id, }); } @@ -78,6 +79,7 @@ export const action: ActionFunction = async ({ request }) => { ChatSystemMessage.send({ room: chatCodeToRevalidate, revalidateOnly: true, + authorUserId: user.id, }); } diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index d44a49749..f669e26ce 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -347,6 +347,7 @@ export const action: ActionFunction = async ({ params, request }) => { room: tournamentWebsocketRoom(tournament.ctx.id), type: "TOURNAMENT_UPDATED", revalidateOnly: true, + authorUserId: user.id, }, ]); } diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 7b21d0b9f..e0f8e830b 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -24,6 +24,7 @@ import { SendouTabs, } from "~/components/elements/Tabs"; import { useUser } from "~/features/auth/core/user"; +import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { useHydrated } from "~/hooks/useHydrated"; import { useSearchParamState } from "~/hooks/useSearchParamState"; @@ -44,6 +45,7 @@ import * as AbDivisions from "../core/AbDivisions"; import type { Bracket as BracketType } from "../core/Bracket"; import * as PreparedMaps from "../core/PreparedMaps"; import type { Tournament } from "../core/Tournament"; +import { tournamentWebsocketRoom } from "../tournament-bracket-utils"; export { action }; @@ -81,6 +83,11 @@ export default function TournamentBracketsPage() { [tournament, bracketIdx], ); + useWebsocketRevalidation( + tournamentWebsocketRoom(tournament.ctx.id), + !tournament.ctx.isFinalized, + ); + React.useEffect(() => { if (visibility !== "visible" || tournament.ctx.isFinalized) return; diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index 4822656fd..e1b8db9e5 100644 --- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -813,18 +813,19 @@ export const action: ActionFunction = async ({ params, request }) => { clearTournamentDataCache(tournamentId); - // TODO: we could optimize this in the future by including an `authorUserId` field and skip revalidation if the author is the same as the current user if (emitMatchUpdate) { ChatSystemMessage.send([ { room: tournamentMatchWebsocketRoom(matchId), type: "TOURNAMENT_MATCH_UPDATED", revalidateOnly: true, + authorUserId: user.id, }, ...endedDroppedMatchIds.map((id) => ({ room: tournamentMatchWebsocketRoom(id), type: "TOURNAMENT_MATCH_UPDATED" as const, revalidateOnly: true as const, + authorUserId: user.id, })), ]); } @@ -834,6 +835,7 @@ export const action: ActionFunction = async ({ params, request }) => { room: tournamentWebsocketRoom(tournament.ctx.id), type: "TOURNAMENT_UPDATED", revalidateOnly: true, + authorUserId: user.id, }, ]); } diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx index 8214d3a30..6f73c5fe8 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx @@ -1,6 +1,7 @@ import { useLoaderData } from "react-router"; import { containerClassName } from "~/components/Main"; import { MatchPage } from "~/components/match-page/MatchPage"; +import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { action } from "../actions/to.$id.matches.$mid.server"; import { TournamentMatchBanner } from "../components/TournamentMatchBanner"; @@ -8,6 +9,7 @@ import { TournamentMatchHeader } from "../components/TournamentMatchHeader"; import { TournamentMatchTabs } from "../components/TournamentMatchTabs"; import { loader } from "../loaders/to.$id.matches.$mid.server"; import { MatchPageProvider } from "../match-page-context"; +import { tournamentMatchWebsocketRoom } from "../tournament-match-utils"; export { action, loader }; @@ -18,6 +20,11 @@ export const handle: SendouRouteHandle = { export default function TournamentMatchPage() { const data = useLoaderData(); + useWebsocketRevalidation( + tournamentMatchWebsocketRoom(data.match.id), + !data.matchIsOver, + ); + return ( diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts index 2f8e0f406..576683809 100644 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ b/app/features/tournament/actions/to.$id.admin.server.ts @@ -410,11 +410,13 @@ export const action: ActionFunction = async ({ request, params }) => { room: tournamentMatchWebsocketRoom(matchId), type: "TOURNAMENT_MATCH_UPDATED" as const, revalidateOnly: true as const, + authorUserId: user.id, })), { room: tournamentWebsocketRoom(tournament.ctx.id), type: "TOURNAMENT_UPDATED" as const, revalidateOnly: true as const, + authorUserId: user.id, }, ]); }