mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Refactor tournament realtime from SSE to Websocket (#2469)
This commit is contained in:
parent
5d4d8c40b5
commit
3d9995622b
3
app/components/Placeholder.module.css
Normal file
3
app/components/Placeholder.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.placeholder {
|
||||
height: 100vh;
|
||||
}
|
||||
6
app/components/Placeholder.tsx
Normal file
6
app/components/Placeholder.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
public/static-assets/sounds/tournament_match.wav
Normal file
BIN
public/static-assets/sounds/tournament_match.wav
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user