Refactor tournament realtime from SSE to Websocket (#2469)

This commit is contained in:
Kalle 2025-08-28 18:59:34 +03:00 committed by GitHub
parent 5d4d8c40b5
commit 3d9995622b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 414 additions and 438 deletions

View File

@ -0,0 +1,3 @@
.placeholder {
height: 100vh;
}

View File

@ -0,0 +1,6 @@
import styles from "./Placeholder.module.css";
/** Renders a blank placeholder component that can be used while content is loading. Better than returning null because it keeps the footer down where it belongs. */
export function Placeholder() {
return <div className={styles.placeholder} />;
}

View File

@ -86,6 +86,7 @@ import {
import "../analyzer.css";
import * as R from "remeda";
import { SendouSwitch } from "~/components/elements/Switch";
import { Placeholder } from "~/components/Placeholder";
import { WeaponSelect } from "~/components/WeaponSelect";
import { logger } from "~/utils/logger";
@ -117,7 +118,7 @@ export default function BuildAnalyzerShell() {
const isMounted = useIsMounted();
if (!isMounted) {
return null;
return <Placeholder />;
}
return <BuildAnalyzerPage />;

View File

@ -1,6 +1,13 @@
import { useRevalidator } from "@remix-run/react";
import { nanoid } from "nanoid";
import { WebSocket } from "partysocket";
import React from "react";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { soundPath } from "~/utils/urls";
import { useUser } from "../auth/core/user";
import type { ChatMessage } from "./chat-types";
import type { ChatMessage, ChatProps } from "./chat-types";
import { messageTypeToSound, soundEnabled, soundVolume } from "./chat-utils";
// increasing this = scrolling happens even when scrolled more upwards
const THRESHOLD = 100;
@ -69,3 +76,211 @@ export function useChatAutoScroll(
scrollToBottom,
};
}
// TODO: should contain unseen messages logic, now it's duplicated
export function useChat({
rooms,
onNewMessage,
revalidates = true,
connected = true,
}: {
/** Which chat rooms to join. */
rooms: ChatProps["rooms"];
/** Callback function when a new chat message is received. Note: not fired for system messages. */
onNewMessage?: (message: ChatMessage) => void;
/** If false, skips revalidating on new message. Can be used if more fine grained control is needed regarding when the revalidation happens to e.g. preserve local state. Defaults to true. */
revalidates?: boolean;
/** If true, the chat is connected to the server. Defaults to true. */
connected?: boolean;
}) {
const { revalidate } = useRevalidator();
const shouldRevalidate = React.useRef<boolean>();
const user = useUser();
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
const [readyState, setReadyState] = React.useState<
"CONNECTING" | "CONNECTED" | "CLOSED"
>("CONNECTING");
const [sentMessage, setSentMessage] = React.useState<ChatMessage>();
const [currentRoom, setCurrentRoom] = React.useState<string | undefined>(
rooms[0]?.code,
);
const ws = React.useRef<WebSocket>();
const lastSeenMessagesByRoomId = React.useRef<Map<string, string>>(new Map());
// same principal as here behind separating it into a ref: https://overreacted.io/making-setinterval-declarative-with-react-hooks/
React.useEffect(() => {
shouldRevalidate.current = revalidates;
}, [revalidates]);
React.useEffect(() => {
if (rooms.length === 0 || !connected) return;
if (!import.meta.env.VITE_SKALOP_WS_URL) {
logger.warn("No WS URL provided");
return;
}
const url = `${import.meta.env.VITE_SKALOP_WS_URL}?${rooms
.map((room) => `room=${room.code}`)
.join("&")}`;
ws.current = new WebSocket(url, [], {
maxReconnectionDelay: 10000 * 2,
reconnectionDelayGrowFactor: 1.5,
});
ws.current.onopen = () => {
setCurrentRoom(rooms[0].code);
setReadyState("CONNECTED");
};
ws.current.onclose = () => setReadyState("CLOSED");
ws.current.onerror = () => setReadyState("CLOSED");
ws.current.onmessage = (e) => {
const message = JSON.parse(e.data);
const messageArr = (
Array.isArray(message) ? message : [message]
) as ChatMessage[];
// something interesting happened
// -> let's run data loaders so they can see it without needing to refresh the page
const isSystemMessage = Boolean(messageArr[0].type);
if (isSystemMessage && shouldRevalidate.current) {
revalidate();
}
const sound = messageTypeToSound(messageArr[0].type);
if (sound && soundEnabled(sound)) {
const audio = new Audio(soundPath(sound));
audio.volume = soundVolume() / 100;
void audio
.play()
.catch((e) => logger.error(`Couldn't play sound: ${e}`));
}
if (messageArr[0].revalidateOnly) {
return;
}
const isInitialLoad = Array.isArray(message);
if (isInitialLoad) {
lastSeenMessagesByRoomId.current = message.reduce((acc, cur) => {
acc.set(cur.room, cur.id);
return acc;
}, new Map<string, string>());
}
if (isInitialLoad) {
setMessages(messageArr);
} else {
if (!isSystemMessage) onNewMessage?.(message);
setMessages((messages) => [...messages, ...messageArr]);
}
};
const wsCurrent = ws.current;
return () => {
wsCurrent?.close();
setMessages([]);
};
}, [rooms, onNewMessage, revalidate, connected]);
React.useEffect(() => {
// ping every minute to keep connection alive
const interval = setInterval(() => {
ws.current?.send("");
}, 1000 * 60);
return () => {
clearInterval(interval);
};
}, []);
const send = React.useCallback(
(contents: string) => {
invariant(currentRoom);
const id = nanoid();
setSentMessage({
id,
room: currentRoom,
contents,
timestamp: Date.now(),
userId: user!.id,
});
ws.current!.send(JSON.stringify({ id, contents, room: currentRoom }));
},
[user, currentRoom],
);
let allMessages = messages;
if (sentMessage && !messages.some((msg) => msg.id === sentMessage.id)) {
allMessages = [...messages, { ...sentMessage, pending: true }];
}
const roomsMessages = allMessages
.filter((msg) => msg.room === currentRoom)
.sort((a, b) => a.timestamp - b.timestamp);
if (roomsMessages.length > 0 && currentRoom) {
lastSeenMessagesByRoomId.current.set(
currentRoom,
roomsMessages[roomsMessages.length - 1].id,
);
}
const unseenMessages = unseenMessagesCountByRoomId({
messages,
lastSeenMessages: lastSeenMessagesByRoomId.current,
});
return {
messages: roomsMessages,
send,
currentRoom,
setCurrentRoom,
readyState,
unseenMessages,
};
}
/** Listens to system messages sent via WebSocket to the given room triggering data loader revalidations. */
export function useWebsocketRevalidation({
room,
connected,
}: {
room: string;
/** If true, the websocket is connected. Defaults to true. */
connected?: boolean;
}) {
const rooms = React.useMemo(() => [{ label: room, code: room }], [room]);
useChat({
rooms,
connected,
});
}
function unseenMessagesCountByRoomId({
messages,
lastSeenMessages,
}: {
messages: ChatMessage[];
lastSeenMessages: Map<string, string>;
}) {
const lastUnseenEncountered = new Set<string>();
const unseenMessages = messages.filter((msg) => {
if (msg.id === lastSeenMessages.get(msg.room)) {
lastUnseenEncountered.add(msg.room);
return false;
}
return lastUnseenEncountered.has(msg.room);
});
return unseenMessages.reduce((acc, cur) => {
const count = acc.get(cur.room) ?? 0;
acc.set(cur.room, count + 1);
return acc;
}, new Map<string, number>());
}

View File

@ -1,3 +1,5 @@
import type { Tables } from "~/db/tables";
export type SystemMessageType =
| "NEW_GROUP"
| "USER_LEFT"
@ -6,7 +8,9 @@ export type SystemMessageType =
| "SCORE_REPORTED"
| "SCORE_CONFIRMED"
| "CANCEL_REPORTED"
| "CANCEL_CONFIRMED";
| "CANCEL_CONFIRMED"
| "TOURNAMENT_UPDATED"
| "TOURNAMENT_MATCH_UPDATED";
export type SystemMessageContext = {
name: string;
@ -16,9 +20,32 @@ export interface ChatMessage {
type?: SystemMessageType;
contents?: string;
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;
userId?: number;
timestamp: number;
room: string;
pending?: boolean;
}
export type ChatUser = Pick<
Tables["User"],
"username" | "discordId" | "discordAvatar"
> & {
chatNameColor: string | null;
title?: string;
};
export interface ChatProps {
users: Record<number, ChatUser>;
rooms: { label: string; code: string }[];
className?: string;
messagesContainerClassName?: string;
hidden?: boolean;
onNewMessage?: (message: ChatMessage) => void;
onMount?: () => void;
onUnmount?: () => void;
disabled?: boolean;
missingUserName?: string;
revalidates?: boolean;
}

View File

@ -1,45 +1,14 @@
import { useRevalidator } from "@remix-run/react";
import clsx from "clsx";
import { sub } from "date-fns";
import { nanoid } from "nanoid";
import { WebSocket } from "partysocket";
import * as React from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { soundPath } from "~/utils/urls";
import { Avatar } from "../../../components/Avatar";
import { SendouButton } from "../../../components/elements/Button";
import { SubmitButton } from "../../../components/SubmitButton";
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
import { useChatAutoScroll } from "../chat-hooks";
import type { ChatMessage } from "../chat-types";
import { messageTypeToSound, soundEnabled, soundVolume } from "../chat-utils";
export type ChatUser = Pick<
Tables["User"],
"username" | "discordId" | "discordAvatar"
> & {
chatNameColor: string | null;
title?: string;
};
export interface ChatProps {
users: Record<number, ChatUser>;
rooms: { label: string; code: string }[];
className?: string;
messagesContainerClassName?: string;
hidden?: boolean;
onNewMessage?: (message: ChatMessage) => void;
onMount?: () => void;
onUnmount?: () => void;
disabled?: boolean;
missingUserName?: string;
revalidates?: boolean;
}
import { useChat, useChatAutoScroll } from "../chat-hooks";
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
export function ConnectedChat(props: ChatProps) {
const chat = useChat(props);
@ -316,188 +285,3 @@ function MessageTimestamp({ timestamp }: { timestamp: number }) {
</time>
);
}
// TODO: should contain unseen messages logic, now it's duplicated
export function useChat({
rooms,
onNewMessage,
revalidates = true,
}: {
rooms: ChatProps["rooms"];
onNewMessage?: (message: ChatMessage) => void;
revalidates?: boolean;
}) {
const { revalidate } = useRevalidator();
const shouldRevalidate = React.useRef<boolean>();
const user = useUser();
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
const [readyState, setReadyState] = React.useState<
"CONNECTING" | "CONNECTED" | "CLOSED"
>("CONNECTING");
const [sentMessage, setSentMessage] = React.useState<ChatMessage>();
const [currentRoom, setCurrentRoom] = React.useState<string | undefined>(
rooms[0]?.code,
);
const ws = React.useRef<WebSocket>();
const lastSeenMessagesByRoomId = React.useRef<Map<string, string>>(new Map());
// same principal as here behind separating it into a ref: https://overreacted.io/making-setinterval-declarative-with-react-hooks/
React.useEffect(() => {
shouldRevalidate.current = revalidates;
}, [revalidates]);
React.useEffect(() => {
if (rooms.length === 0) return;
if (!import.meta.env.VITE_SKALOP_WS_URL) {
logger.warn("No WS URL provided");
return;
}
const url = `${import.meta.env.VITE_SKALOP_WS_URL}?${rooms
.map((room) => `room=${room.code}`)
.join("&")}`;
ws.current = new WebSocket(url, [], {
maxReconnectionDelay: 10000 * 2,
reconnectionDelayGrowFactor: 1.5,
});
ws.current.onopen = () => {
setCurrentRoom(rooms[0].code);
setReadyState("CONNECTED");
};
ws.current.onclose = () => setReadyState("CLOSED");
ws.current.onerror = () => setReadyState("CLOSED");
ws.current.onmessage = (e) => {
const message = JSON.parse(e.data);
const messageArr = (
Array.isArray(message) ? message : [message]
) as ChatMessage[];
// something interesting happened
// -> let's run data loaders so they can see it sooner
const isSystemMessage = Boolean(messageArr[0].type);
if (isSystemMessage && shouldRevalidate.current) {
revalidate();
}
const sound = messageTypeToSound(messageArr[0].type);
if (sound && soundEnabled(sound)) {
const audio = new Audio(soundPath(sound));
audio.volume = soundVolume() / 100;
void audio
.play()
.catch((e) => logger.error(`Couldn't play sound: ${e}`));
}
if (messageArr[0].revalidateOnly) {
return;
}
const isInitialLoad = Array.isArray(message);
if (isInitialLoad) {
lastSeenMessagesByRoomId.current = message.reduce((acc, cur) => {
acc.set(cur.room, cur.id);
return acc;
}, new Map<string, string>());
}
if (isInitialLoad) {
setMessages(messageArr);
} else {
if (!isSystemMessage) onNewMessage?.(message);
setMessages((messages) => [...messages, ...messageArr]);
}
};
const wsCurrent = ws.current;
return () => {
wsCurrent?.close();
setMessages([]);
};
}, [rooms, onNewMessage, revalidate]);
React.useEffect(() => {
// ping every minute to keep connection alive
const interval = setInterval(() => {
ws.current?.send("");
}, 1000 * 60);
return () => {
clearInterval(interval);
};
}, []);
const send = React.useCallback(
(contents: string) => {
invariant(currentRoom);
const id = nanoid();
setSentMessage({
id,
room: currentRoom,
contents,
timestamp: Date.now(),
userId: user!.id,
});
ws.current!.send(JSON.stringify({ id, contents, room: currentRoom }));
},
[user, currentRoom],
);
let allMessages = messages;
if (sentMessage && !messages.some((msg) => msg.id === sentMessage.id)) {
allMessages = [...messages, { ...sentMessage, pending: true }];
}
const roomsMessages = allMessages
.filter((msg) => msg.room === currentRoom)
.sort((a, b) => a.timestamp - b.timestamp);
if (roomsMessages.length > 0 && currentRoom) {
lastSeenMessagesByRoomId.current.set(
currentRoom,
roomsMessages[roomsMessages.length - 1].id,
);
}
const unseenMessages = unseenMessagesCountByRoomId({
messages,
lastSeenMessages: lastSeenMessagesByRoomId.current,
});
return {
messages: roomsMessages,
send,
currentRoom,
setCurrentRoom,
readyState,
unseenMessages,
};
}
function unseenMessagesCountByRoomId({
messages,
lastSeenMessages,
}: {
messages: ChatMessage[];
lastSeenMessages: Map<string, string>;
}) {
const lastUnseenEncountered = new Set<string>();
const unseenMessages = messages.filter((msg) => {
if (msg.id === lastSeenMessages.get(msg.room)) {
lastUnseenEncountered.add(msg.room);
return false;
}
return lastUnseenEncountered.has(msg.room);
});
return unseenMessages.reduce((acc, cur) => {
const count = acc.get(cur.room) ?? 0;
acc.set(cur.room, count + 1);
return acc;
}, new Map<string, number>());
}

View File

@ -75,10 +75,6 @@ button[data-state="closed"][aria-haspopup="dialog"] {
background-color: var(--bg);
}
.plans__placeholder {
min-height: 100%;
}
.plans__top-section {
position: fixed;
z-index: 10;

View File

@ -1,5 +1,6 @@
import type { MetaFunction } from "@remix-run/node";
import { lazy } from "react";
import { Placeholder } from "~/components/Placeholder";
import { useIsMounted } from "~/hooks/useIsMounted";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -31,7 +32,7 @@ const Planner = lazy(() => import("~/features/map-planner/components/Planner"));
export default function MapPlannerPage() {
const isMounted = useIsMounted();
if (!isMounted) return <div className="plans__placeholder" />;
if (!isMounted) return <Placeholder />;
return <Planner />;
}

View File

@ -34,7 +34,9 @@ import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { Chat, type ChatProps, useChat } from "~/features/chat/components/Chat";
import { useChat } from "~/features/chat/chat-hooks";
import type { ChatProps } from "~/features/chat/chat-types";
import { Chat } from "~/features/chat/components/Chat";
import * as Seasons from "~/features/mmr/core/Seasons";
import { GroupCard } from "~/features/sendouq/components/GroupCard";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";

View File

@ -520,6 +520,10 @@ function SoundCheckboxes() {
code: "sq_match",
name: t("q:settings.sounds.matchStarted"),
},
{
code: "tournament_match",
name: t("q:settings.sounds.tournamentMatchStarted"),
},
];
// default to true

View File

@ -16,7 +16,8 @@ import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { Chat, useChat } from "~/features/chat/components/Chat";
import { useChat } from "~/features/chat/chat-hooks";
import { Chat } from "~/features/chat/components/Chat";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useWindowSize } from "~/hooks/useWindowSize";

View File

@ -1,6 +1,7 @@
import type { ActionFunction } from "@remix-run/node";
import { sql } from "~/db/sql";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
@ -26,7 +27,10 @@ import {
tournamentFromDB,
} from "../core/Tournament.server";
import { bracketSchema } from "../tournament-bracket-schemas.server";
import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils";
import {
fillWithNullTillPowerOfTwo,
tournamentWebsocketRoom,
} from "../tournament-bracket-utils";
export const action: ActionFunction = async ({ params, request }) => {
const user = await requireUser(request);
@ -38,6 +42,8 @@ export const action: ActionFunction = async ({ params, request }) => {
const data = await parseRequestPayload({ request, schema: bracketSchema });
const manager = getServerTournamentManager();
let emitTournamentUpdate = false;
switch (data._action) {
case "START_BRACKET": {
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
@ -136,6 +142,8 @@ export const action: ActionFunction = async ({ params, request }) => {
});
}
emitTournamentUpdate = true;
break;
}
case "PREPARE_MAPS": {
@ -191,6 +199,8 @@ export const action: ActionFunction = async ({ params, request }) => {
await TournamentRepository.insertSwissMatches(matches.value);
emitTournamentUpdate = true;
break;
}
case "UNADVANCE_BRACKET": {
@ -209,6 +219,8 @@ export const action: ActionFunction = async ({ params, request }) => {
roundId: data.roundId,
});
emitTournamentUpdate = true;
break;
}
case "BRACKET_CHECK_IN": {
@ -251,6 +263,9 @@ export const action: ActionFunction = async ({ params, request }) => {
destinationBracketIdx: data.destinationBracketIdx,
tournamentId,
});
emitTournamentUpdate = true;
break;
}
default: {
@ -260,6 +275,16 @@ export const action: ActionFunction = async ({ params, request }) => {
clearTournamentDataCache(tournamentId);
if (emitTournamentUpdate) {
ChatSystemMessage.send([
{
room: tournamentWebsocketRoom(tournament.ctx.id),
type: "TOURNAMENT_UPDATED",
revalidateOnly: true,
},
]);
}
return null;
};

View File

@ -1,7 +1,7 @@
import type { ActionFunction } from "@remix-run/node";
import { nanoid } from "nanoid";
import { sql } from "~/db/sql";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
@ -15,7 +15,6 @@ import {
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
import { emitter } from "../core/emitters.server";
import { resolveMapList } from "../core/mapList.server";
import * as PickBan from "../core/PickBan";
import {
@ -39,11 +38,11 @@ import {
matchSchema,
} from "../tournament-bracket-schemas.server";
import {
bracketSubscriptionKey,
isSetOverByScore,
matchIsLocked,
matchSubscriptionKey,
tournamentMatchWebsocketRoom,
tournamentTeamToActiveRosterUserIds,
tournamentWebsocketRoom,
} from "../tournament-bracket-utils";
export const action: ActionFunction = async ({ params, request }) => {
@ -99,7 +98,7 @@ export const action: ActionFunction = async ({ params, request }) => {
: null;
let emitMatchUpdate = false;
let emitBracketUpdate = false;
let emitTournamentUpdate = false;
switch (data._action) {
case "REPORT_SCORE": {
// they are trying to report score that was already reported
@ -212,7 +211,7 @@ export const action: ActionFunction = async ({ params, request }) => {
})();
emitMatchUpdate = true;
emitBracketUpdate = true;
emitTournamentUpdate = true;
break;
}
@ -311,7 +310,7 @@ export const action: ActionFunction = async ({ params, request }) => {
})();
emitMatchUpdate = true;
emitBracketUpdate = true;
emitTournamentUpdate = true;
break;
}
@ -386,7 +385,7 @@ export const action: ActionFunction = async ({ params, request }) => {
})();
emitMatchUpdate = true;
emitBracketUpdate = true;
emitTournamentUpdate = true;
break;
}
@ -498,7 +497,7 @@ export const action: ActionFunction = async ({ params, request }) => {
})();
emitMatchUpdate = true;
emitBracketUpdate = true;
emitTournamentUpdate = true;
break;
}
@ -514,7 +513,7 @@ export const action: ActionFunction = async ({ params, request }) => {
twitchAccount: data.twitchAccount,
});
emitBracketUpdate = true;
emitTournamentUpdate = true;
break;
}
@ -558,24 +557,28 @@ export const action: ActionFunction = async ({ params, request }) => {
}
}
if (emitMatchUpdate) {
emitter.emit(matchSubscriptionKey(match.id), {
eventId: nanoid(),
userId: user.id,
});
}
if (emitBracketUpdate) {
emitter.emit(bracketSubscriptionKey(tournament.ctx.id), {
matchId: match.id,
scores,
isOver:
scores[0] === Math.ceil(match.bestOf / 2) ||
scores[1] === Math.ceil(match.bestOf / 2),
});
}
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,
},
]);
}
if (emitTournamentUpdate) {
ChatSystemMessage.send([
{
room: tournamentWebsocketRoom(tournament.ctx.id),
type: "TOURNAMENT_UPDATED",
revalidateOnly: true,
},
]);
}
return null;
};

View File

@ -18,7 +18,8 @@ import { CrossIcon } from "~/components/icons/Cross";
import { PickIcon } from "~/components/icons/Pick";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { Chat, useChat } from "~/features/chat/components/Chat";
import { useChat } from "~/features/chat/chat-hooks";
import { Chat } from "~/features/chat/components/Chat";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";

View File

@ -7,9 +7,14 @@ import { SendouPopover } from "~/components/elements/Popover";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { soundEnabled, soundVolume } from "~/features/chat/chat-utils";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { logger } from "~/utils/logger";
import { tournamentMatchPage, tournamentRegisterPage } from "~/utils/urls";
import {
soundPath,
tournamentMatchPage,
tournamentRegisterPage,
} from "~/utils/urls";
export function TournamentTeamActions() {
const tournament = useTournament();
@ -18,6 +23,8 @@ export function TournamentTeamActions() {
const status = tournament.teamMemberOfProgressStatus(user);
useMatchReadySound(status?.type);
if (!status) return null;
if (status.type === "MATCH") {
@ -199,3 +206,25 @@ function Dots() {
</span>
);
}
function useMatchReadySound(statusType?: string) {
const isWaiting = React.useRef(false);
React.useEffect(() => {
if (statusType === "MATCH" && isWaiting.current) {
const sound = "tournament_match";
if (soundEnabled(sound)) {
const audio = new Audio(soundPath(sound));
audio.volume = soundVolume() / 100;
void audio
.play()
.catch((e) => logger.error(`Couldn't play sound: ${e}`));
}
}
isWaiting.current = !statusType || statusType?.startsWith("WAITING_");
}, [statusType]);
return isWaiting;
}

View File

@ -1,11 +0,0 @@
import { EventEmitter } from "node:events";
const globalForEmitter = global as unknown as {
emitter: EventEmitter | undefined;
};
export const emitter = globalForEmitter.emitter ?? new EventEmitter();
// the default of 10 is not relevant for us because we use it for server-sent events
emitter.setMaxListeners(0);
globalForEmitter.emitter = emitter;

View File

@ -1,33 +0,0 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { eventStream } from "remix-utils/sse/server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { emitter } from "../core/emitters.server";
import { bracketSubscriptionKey } from "../tournament-bracket-utils";
export const loader = ({ request, params }: LoaderFunctionArgs) => {
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
return eventStream(request.signal, (send) => {
const handler = (args: {
matchId: number;
scores: [number, number];
isOver: boolean;
}) => {
send({
event: bracketSubscriptionKey(tournamentId),
data: `${args.matchId}-${args.scores[0]}-${args.scores[1]}-${String(
args.isOver,
)}`,
});
};
emitter.addListener(bracketSubscriptionKey(tournamentId), handler);
return () => {
emitter.removeListener(bracketSubscriptionKey(tournamentId), handler);
};
});
};

View File

@ -5,7 +5,6 @@ import * as React from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import { useEventSource } from "remix-utils/sse/react";
import { Alert } from "~/components/Alert";
import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
@ -16,15 +15,13 @@ import { EyeIcon } from "~/components/icons/Eye";
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
import { MapIcon } from "~/components/icons/Map";
import { useUser } from "~/features/auth/core/user";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import {
SENDOU_INK_BASE_URL,
tournamentBracketsSubscribePage,
tournamentJoinPage,
} from "~/utils/urls";
import { SENDOU_INK_BASE_URL, tournamentJoinPage } from "~/utils/urls";
import {
useBracketExpanded,
useTournament,
@ -36,11 +33,10 @@ import { BracketMapListDialog } from "../components/BracketMapListDialog";
import { TournamentTeamActions } from "../components/TournamentTeamActions";
import type { Bracket as BracketType } from "../core/Bracket";
import * as PreparedMaps from "../core/PreparedMaps";
import { bracketSubscriptionKey } from "../tournament-bracket-utils";
export { action };
import "../components/Bracket/bracket.css";
import "../tournament-bracket.css";
import "../components/Bracket/bracket.css";
export default function TournamentBracketsPage() {
const { t } = useTranslation(["tournament"]);
@ -51,6 +47,11 @@ export default function TournamentBracketsPage() {
const isMounted = useIsMounted();
const ctx = useOutletContext();
useWebsocketRevalidation({
room: tournamentWebsocketRoom(tournament.ctx.id),
connected: !tournament.ctx.isFinalized,
});
const defaultBracketIdx = () => {
if (
tournament.brackets.length === 1 ||
@ -74,10 +75,10 @@ export default function TournamentBracketsPage() {
);
React.useEffect(() => {
if (visibility !== "visible" || tournament.everyBracketOver) return;
if (visibility !== "visible" || tournament.ctx.isFinalized) return;
revalidate();
}, [visibility, revalidate, tournament.everyBracketOver]);
}, [visibility, revalidate, tournament.ctx.isFinalized]);
const showAddSubsButton =
!tournament.canFinalize(user) &&
@ -160,9 +161,6 @@ export default function TournamentBracketsPage() {
return (
<div>
<Outlet context={ctx} />
{visibility !== "hidden" && !tournament.everyBracketOver ? (
<AutoRefresher />
) : null}
{tournament.canFinalize(user) ? (
<div className="tournament-bracket__finalize">
<LinkButton
@ -270,30 +268,6 @@ export default function TournamentBracketsPage() {
);
}
function AutoRefresher() {
useAutoRefresh();
return null;
}
function useAutoRefresh() {
const { revalidate } = useRevalidator();
const tournament = useTournament();
const lastEvent = useEventSource(
tournamentBracketsSubscribePage(tournament.ctx.id),
{
event: bracketSubscriptionKey(tournament.ctx.id),
},
);
React.useEffect(() => {
if (!lastEvent) return;
// TODO: maybe later could look into not revalidating unless bracket advanced but do something fancy in the tournament class instead
revalidate();
}, [lastEvent, revalidate]);
}
function BracketStarter({
bracket,
bracketIdx,

View File

@ -1,30 +0,0 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { eventStream } from "remix-utils/sse/server";
import { getUserId } from "~/features/auth/core/user.server";
import { parseParams } from "~/utils/remix.server";
import { emitter } from "../core/emitters.server";
import { matchPageParamsSchema } from "../tournament-bracket-schemas.server";
import { matchSubscriptionKey } from "../tournament-bracket-utils";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const loggedInUser = await getUserId(request);
const matchId = parseParams({
params,
schema: matchPageParamsSchema,
}).mid;
return eventStream(request.signal, (send) => {
const handler = (args: { eventId: string; userId: number }) => {
// small optimization not to send the event
// if the user is the one who triggered the event
if (args.userId === loggedInUser?.id) return;
send({ event: matchSubscriptionKey(matchId), data: args.eventId });
};
emitter.addListener(matchSubscriptionKey(matchId), handler);
return () => {
emitter.removeListener(matchSubscriptionKey(matchId), handler);
};
});
};

View File

@ -1,11 +1,11 @@
import { useLoaderData, useRevalidator } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useEventSource } from "remix-utils/sse/react";
import { LinkButton } from "~/components/elements/Button";
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
import { containerClassName } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import { ConnectedChat } from "~/features/chat/components/Chat";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
@ -13,10 +13,7 @@ import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import invariant from "~/utils/invariant";
import { assertUnreachable } from "~/utils/types";
import {
tournamentBracketsPage,
tournamentMatchSubscribePage,
} from "~/utils/urls";
import { tournamentBracketsPage } from "~/utils/urls";
import { action } from "../actions/to.$id.matches.$mid.server";
import { CastInfo } from "../components/CastInfo";
import { MatchRosters } from "../components/MatchRosters";
@ -26,7 +23,7 @@ import { getRounds } from "../core/rounds";
import { loader } from "../loaders/to.$id.matches.$mid.server";
import {
groupNumberToLetters,
matchSubscriptionKey,
tournamentMatchWebsocketRoom,
} from "../tournament-bracket-utils";
export { action, loader };
@ -39,11 +36,16 @@ export default function TournamentMatchPage() {
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
useWebsocketRevalidation({
room: tournamentMatchWebsocketRoom(data.match.id),
connected: !tournament.ctx.isFinalized,
});
React.useEffect(() => {
if (visibility !== "visible" || data.matchIsOver) return;
if (visibility !== "visible" || tournament.ctx.isFinalized) return;
revalidate();
}, [visibility, revalidate, data.matchIsOver]);
}, [visibility, revalidate, tournament.ctx.isFinalized]);
const type =
tournament.canReportScore({ matchId: data.match.id, user }) ||
@ -75,7 +77,6 @@ export default function TournamentMatchPage() {
return (
<div className={clsx("stack lg", containerClassName("normal"))}>
{!data.matchIsOver && visibility !== "hidden" ? <AutoRefresher /> : null}
<div className="flex horizontal justify-between items-center">
<MatchHeader />
<div className="stack md horizontal flex-wrap-reverse justify-end">
@ -282,33 +283,6 @@ function MatchHeader() {
);
}
function AutoRefresher() {
useAutoRefresh();
return null;
}
function useAutoRefresh() {
const { revalidate } = useRevalidator();
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
const lastEventId = useEventSource(
tournamentMatchSubscribePage({
tournamentId: tournament.ctx.id,
matchId: data.match.id,
}),
{
event: matchSubscriptionKey(data.match.id),
},
);
React.useEffect(() => {
if (lastEventId) {
revalidate();
}
}, [lastEventId, revalidate]);
}
function MapListSection({
teams,
type,

View File

@ -16,6 +16,12 @@ import type { Standing } from "./core/Bracket";
import type { Tournament } from "./core/Tournament";
import type { TournamentDataTeam } from "./core/Tournament.server";
export const tournamentWebsocketRoom = (tournamentId: number) =>
`tournament__${tournamentId}`;
export const tournamentMatchWebsocketRoom = (matchId: number) =>
`match__${matchId}`;
const NUM_MAP = {
"1": ["1", "2", "4"],
"2": ["2", "1", "3", "5"],
@ -96,14 +102,6 @@ export function checkSourceIsValid({
return false;
}
export function bracketSubscriptionKey(tournamentId: number) {
return `BRACKET_CHANGED_${tournamentId}`;
}
export function matchSubscriptionKey(matchId: number) {
return `MATCH_CHANGED_${matchId}`;
}
export function fillWithNullTillPowerOfTwo<T>(arr: T[]) {
const nextPowerOfTwo = 2 ** Math.ceil(Math.log2(arr.length));
const nullsToAdd = nextPowerOfTwo - arr.length;

View File

@ -8,6 +8,7 @@ import {
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { useUser } from "~/features/auth/core/user";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
@ -32,7 +33,9 @@ import "../tournament.css";
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
const navigatedToMatchPage =
typeof args.nextParams.mid === "string" && args.formMethod !== "POST";
typeof args.nextParams.mid === "string" &&
args.formMethod !== "POST" &&
args.currentParams.mid !== args.nextParams.mid;
if (navigatedToMatchPage) return false;
@ -101,7 +104,7 @@ export default function TournamentLayoutShell() {
if (!isMounted)
return (
<Main bigger>
<div className="tournament__placeholder" />
<Placeholder />
</Main>
);

View File

@ -1,7 +1,3 @@
.tournament__placeholder {
min-height: 100vh;
}
.tournament__action-section {
padding: var(--s-0-5) var(--s-6) var(--s-6) var(--s-6);
border-radius: var(--rounded);

View File

@ -10,7 +10,6 @@ import type {
TablesInsertable,
UserPreferences,
} from "~/db/tables";
import type { ChatUser } from "~/features/chat/components/Chat";
import { userRoles } from "~/modules/permissions/mapper.server";
import { isSupporter } from "~/modules/permissions/utils";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
@ -18,6 +17,7 @@ import invariant from "~/utils/invariant";
import type { CommonUser } from "~/utils/kysely.server";
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
import { safeNumberParse } from "~/utils/number";
import type { ChatUser } from "../chat/chat-types";
const identifierToUserIdQuery = (identifier: string) =>
db

View File

@ -113,18 +113,10 @@ export default [
),
],
),
route(
"brackets/subscribe",
"features/tournament-bracket/routes/to.$id.brackets.subscribe.ts",
),
route(
"matches/:mid",
"features/tournament-bracket/routes/to.$id.matches.$mid.tsx",
),
route(
"matches/:mid/subscribe",
"features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.ts",
),
]),
route("luti", "features/tournament/routes/luti.ts"),

View File

@ -334,8 +334,6 @@ export const tournamentDivisionsPage = (tournamentId: number) =>
`/to/${tournamentId}/divisions`;
export const tournamentResultsPage = (tournamentId: number) =>
`/to/${tournamentId}/results`;
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
`/to/${tournamentId}/brackets/subscribe`;
export const tournamentMatchPage = ({
tournamentId,
matchId,
@ -343,13 +341,6 @@ export const tournamentMatchPage = ({
tournamentId: number;
matchId: number;
}) => `/to/${tournamentId}/matches/${matchId}`;
export const tournamentMatchSubscribePage = ({
tournamentId,
matchId,
}: {
tournamentId: number;
matchId: number;
}) => `/to/${tournamentId}/matches/${matchId}/subscribe`;
export const tournamentJoinPage = ({
tournamentId,
inviteCode,

View File

@ -192,9 +192,32 @@ Cron jobs to perform actions on the server at certain intervals. To add a new on
### Real time
Webhooks via Skalop service (see logic in the Chat module).
Webhooks via Skalop service (see logic in the Chat module). In short an action file can send an update via the `ChatSystemMessage` module:
Old way: server-sent events still in use for tournament bracket & match pages.
```ts
ChatSystemMessage.send([
{
room: `tournament__${tournament.id}`,
type: "TOURNAMENT_UPDATED",
revalidateOnly: true,
},
]);
```
which then can be listed to in a React component via the `useChat` hook:
```tsx
const chatRooms = React.useMemo(
() => [
{ code: `tournament__${tournament.id}`, label: "Tournament" },
],
[tournament.ctx.id],
);
useChat({
rooms: chatRooms,
connected: !tournament.ctx.isFinalized,
});
```
### Notifications

View File

@ -67,6 +67,7 @@
"settings.sounds.likeReceived": "Like received",
"settings.sounds.groupNewMember": "Group new member",
"settings.sounds.matchStarted": "Match started",
"settings.sounds.tournamentMatchStarted": "Tournament match started",
"settings.mapPool.notOk": "Pick {{count}} stages per mode that you didn't avoid to save your preferences",
"settings.misc.header": "Misc",
"settings.banned": "Banned",

Binary file not shown.