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 "../analyzer.css";
|
||||||
import * as R from "remeda";
|
import * as R from "remeda";
|
||||||
import { SendouSwitch } from "~/components/elements/Switch";
|
import { SendouSwitch } from "~/components/elements/Switch";
|
||||||
|
import { Placeholder } from "~/components/Placeholder";
|
||||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||||
import { logger } from "~/utils/logger";
|
import { logger } from "~/utils/logger";
|
||||||
|
|
||||||
|
|
@ -117,7 +118,7 @@ export default function BuildAnalyzerShell() {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return null;
|
return <Placeholder />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <BuildAnalyzerPage />;
|
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 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 { 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
|
// increasing this = scrolling happens even when scrolled more upwards
|
||||||
const THRESHOLD = 100;
|
const THRESHOLD = 100;
|
||||||
|
|
@ -69,3 +76,211 @@ export function useChatAutoScroll(
|
||||||
scrollToBottom,
|
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 =
|
export type SystemMessageType =
|
||||||
| "NEW_GROUP"
|
| "NEW_GROUP"
|
||||||
| "USER_LEFT"
|
| "USER_LEFT"
|
||||||
|
|
@ -6,7 +8,9 @@ export type SystemMessageType =
|
||||||
| "SCORE_REPORTED"
|
| "SCORE_REPORTED"
|
||||||
| "SCORE_CONFIRMED"
|
| "SCORE_CONFIRMED"
|
||||||
| "CANCEL_REPORTED"
|
| "CANCEL_REPORTED"
|
||||||
| "CANCEL_CONFIRMED";
|
| "CANCEL_CONFIRMED"
|
||||||
|
| "TOURNAMENT_UPDATED"
|
||||||
|
| "TOURNAMENT_MATCH_UPDATED";
|
||||||
|
|
||||||
export type SystemMessageContext = {
|
export type SystemMessageContext = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -16,9 +20,32 @@ export interface ChatMessage {
|
||||||
type?: SystemMessageType;
|
type?: SystemMessageType;
|
||||||
contents?: string;
|
contents?: string;
|
||||||
context?: SystemMessageContext;
|
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;
|
revalidateOnly?: boolean;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
room: string;
|
room: string;
|
||||||
pending?: boolean;
|
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 clsx from "clsx";
|
||||||
import { sub } from "date-fns";
|
import { sub } from "date-fns";
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { WebSocket } from "partysocket";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Button } from "react-aria-components";
|
import { Button } from "react-aria-components";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { Avatar } from "../../../components/Avatar";
|
||||||
import { SendouButton } from "../../../components/elements/Button";
|
import { SendouButton } from "../../../components/elements/Button";
|
||||||
import { SubmitButton } from "../../../components/SubmitButton";
|
import { SubmitButton } from "../../../components/SubmitButton";
|
||||||
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
|
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
|
||||||
import { useChatAutoScroll } from "../chat-hooks";
|
import { useChat, useChatAutoScroll } from "../chat-hooks";
|
||||||
import type { ChatMessage } from "../chat-types";
|
import type { ChatMessage, ChatProps, ChatUser } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConnectedChat(props: ChatProps) {
|
export function ConnectedChat(props: ChatProps) {
|
||||||
const chat = useChat(props);
|
const chat = useChat(props);
|
||||||
|
|
@ -316,188 +285,3 @@ function MessageTimestamp({ timestamp }: { timestamp: number }) {
|
||||||
</time>
|
</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);
|
background-color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans__placeholder {
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plans__top-section {
|
.plans__top-section {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { MetaFunction } from "@remix-run/node";
|
import type { MetaFunction } from "@remix-run/node";
|
||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
|
import { Placeholder } from "~/components/Placeholder";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { metaTags } from "~/utils/remix";
|
import { metaTags } from "~/utils/remix";
|
||||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||||
|
|
@ -31,7 +32,7 @@ const Planner = lazy(() => import("~/features/map-planner/components/Planner"));
|
||||||
export default function MapPlannerPage() {
|
export default function MapPlannerPage() {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
if (!isMounted) return <div className="plans__placeholder" />;
|
if (!isMounted) return <Placeholder />;
|
||||||
|
|
||||||
return <Planner />;
|
return <Planner />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||||
import type { Tables } from "~/db/tables";
|
import type { Tables } from "~/db/tables";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
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 * as Seasons from "~/features/mmr/core/Seasons";
|
||||||
import { GroupCard } from "~/features/sendouq/components/GroupCard";
|
import { GroupCard } from "~/features/sendouq/components/GroupCard";
|
||||||
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,10 @@ function SoundCheckboxes() {
|
||||||
code: "sq_match",
|
code: "sq_match",
|
||||||
name: t("q:settings.sounds.matchStarted"),
|
name: t("q:settings.sounds.matchStarted"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: "tournament_match",
|
||||||
|
name: t("q:settings.sounds.tournamentMatchStarted"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// default to true
|
// default to true
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ import { Image } from "~/components/Image";
|
||||||
import { Main } from "~/components/Main";
|
import { Main } from "~/components/Main";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
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 { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ActionFunction } from "@remix-run/node";
|
import type { ActionFunction } from "@remix-run/node";
|
||||||
import { sql } from "~/db/sql";
|
import { sql } from "~/db/sql";
|
||||||
import { requireUser } from "~/features/auth/core/user.server";
|
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 { notify } from "~/features/notifications/core/notify.server";
|
||||||
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
|
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
|
||||||
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
|
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
|
||||||
|
|
@ -26,7 +27,10 @@ import {
|
||||||
tournamentFromDB,
|
tournamentFromDB,
|
||||||
} from "../core/Tournament.server";
|
} from "../core/Tournament.server";
|
||||||
import { bracketSchema } from "../tournament-bracket-schemas.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 }) => {
|
export const action: ActionFunction = async ({ params, request }) => {
|
||||||
const user = await requireUser(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 data = await parseRequestPayload({ request, schema: bracketSchema });
|
||||||
const manager = getServerTournamentManager();
|
const manager = getServerTournamentManager();
|
||||||
|
|
||||||
|
let emitTournamentUpdate = false;
|
||||||
|
|
||||||
switch (data._action) {
|
switch (data._action) {
|
||||||
case "START_BRACKET": {
|
case "START_BRACKET": {
|
||||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||||
|
|
@ -136,6 +142,8 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "PREPARE_MAPS": {
|
case "PREPARE_MAPS": {
|
||||||
|
|
@ -191,6 +199,8 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
|
|
||||||
await TournamentRepository.insertSwissMatches(matches.value);
|
await TournamentRepository.insertSwissMatches(matches.value);
|
||||||
|
|
||||||
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "UNADVANCE_BRACKET": {
|
case "UNADVANCE_BRACKET": {
|
||||||
|
|
@ -209,6 +219,8 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
roundId: data.roundId,
|
roundId: data.roundId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "BRACKET_CHECK_IN": {
|
case "BRACKET_CHECK_IN": {
|
||||||
|
|
@ -251,6 +263,9 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
destinationBracketIdx: data.destinationBracketIdx,
|
destinationBracketIdx: data.destinationBracketIdx,
|
||||||
tournamentId,
|
tournamentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|
@ -260,6 +275,16 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
|
|
||||||
clearTournamentDataCache(tournamentId);
|
clearTournamentDataCache(tournamentId);
|
||||||
|
|
||||||
|
if (emitTournamentUpdate) {
|
||||||
|
ChatSystemMessage.send([
|
||||||
|
{
|
||||||
|
room: tournamentWebsocketRoom(tournament.ctx.id),
|
||||||
|
type: "TOURNAMENT_UPDATED",
|
||||||
|
revalidateOnly: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ActionFunction } from "@remix-run/node";
|
import type { ActionFunction } from "@remix-run/node";
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { sql } from "~/db/sql";
|
import { sql } from "~/db/sql";
|
||||||
import { requireUser } from "~/features/auth/core/user.server";
|
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 TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||||
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
|
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
} from "~/utils/remix.server";
|
} from "~/utils/remix.server";
|
||||||
import { assertUnreachable } from "~/utils/types";
|
import { assertUnreachable } from "~/utils/types";
|
||||||
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
|
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
|
||||||
import { emitter } from "../core/emitters.server";
|
|
||||||
import { resolveMapList } from "../core/mapList.server";
|
import { resolveMapList } from "../core/mapList.server";
|
||||||
import * as PickBan from "../core/PickBan";
|
import * as PickBan from "../core/PickBan";
|
||||||
import {
|
import {
|
||||||
|
|
@ -39,11 +38,11 @@ import {
|
||||||
matchSchema,
|
matchSchema,
|
||||||
} from "../tournament-bracket-schemas.server";
|
} from "../tournament-bracket-schemas.server";
|
||||||
import {
|
import {
|
||||||
bracketSubscriptionKey,
|
|
||||||
isSetOverByScore,
|
isSetOverByScore,
|
||||||
matchIsLocked,
|
matchIsLocked,
|
||||||
matchSubscriptionKey,
|
tournamentMatchWebsocketRoom,
|
||||||
tournamentTeamToActiveRosterUserIds,
|
tournamentTeamToActiveRosterUserIds,
|
||||||
|
tournamentWebsocketRoom,
|
||||||
} from "../tournament-bracket-utils";
|
} from "../tournament-bracket-utils";
|
||||||
|
|
||||||
export const action: ActionFunction = async ({ params, request }) => {
|
export const action: ActionFunction = async ({ params, request }) => {
|
||||||
|
|
@ -99,7 +98,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let emitMatchUpdate = false;
|
let emitMatchUpdate = false;
|
||||||
let emitBracketUpdate = false;
|
let emitTournamentUpdate = false;
|
||||||
switch (data._action) {
|
switch (data._action) {
|
||||||
case "REPORT_SCORE": {
|
case "REPORT_SCORE": {
|
||||||
// they are trying to report score that was already reported
|
// they are trying to report score that was already reported
|
||||||
|
|
@ -212,7 +211,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
emitMatchUpdate = true;
|
emitMatchUpdate = true;
|
||||||
emitBracketUpdate = true;
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +310,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
emitMatchUpdate = true;
|
emitMatchUpdate = true;
|
||||||
emitBracketUpdate = true;
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +385,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
emitMatchUpdate = true;
|
emitMatchUpdate = true;
|
||||||
emitBracketUpdate = true;
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -498,7 +497,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
emitMatchUpdate = true;
|
emitMatchUpdate = true;
|
||||||
emitBracketUpdate = true;
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -514,7 +513,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
twitchAccount: data.twitchAccount,
|
twitchAccount: data.twitchAccount,
|
||||||
});
|
});
|
||||||
|
|
||||||
emitBracketUpdate = true;
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
break;
|
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);
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ import { CrossIcon } from "~/components/icons/Cross";
|
||||||
import { PickIcon } from "~/components/icons/Pick";
|
import { PickIcon } from "~/components/icons/Pick";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
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 { useTournament } from "~/features/tournament/routes/to.$id";
|
||||||
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ import { SendouPopover } from "~/components/elements/Popover";
|
||||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
|
import { soundEnabled, soundVolume } from "~/features/chat/chat-utils";
|
||||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||||
import { logger } from "~/utils/logger";
|
import { logger } from "~/utils/logger";
|
||||||
import { tournamentMatchPage, tournamentRegisterPage } from "~/utils/urls";
|
import {
|
||||||
|
soundPath,
|
||||||
|
tournamentMatchPage,
|
||||||
|
tournamentRegisterPage,
|
||||||
|
} from "~/utils/urls";
|
||||||
|
|
||||||
export function TournamentTeamActions() {
|
export function TournamentTeamActions() {
|
||||||
const tournament = useTournament();
|
const tournament = useTournament();
|
||||||
|
|
@ -18,6 +23,8 @@ export function TournamentTeamActions() {
|
||||||
|
|
||||||
const status = tournament.teamMemberOfProgressStatus(user);
|
const status = tournament.teamMemberOfProgressStatus(user);
|
||||||
|
|
||||||
|
useMatchReadySound(status?.type);
|
||||||
|
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
|
|
||||||
if (status.type === "MATCH") {
|
if (status.type === "MATCH") {
|
||||||
|
|
@ -199,3 +206,25 @@ function Dots() {
|
||||||
</span>
|
</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 { ErrorBoundary } from "react-error-boundary";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCopyToClipboard } from "react-use";
|
import { useCopyToClipboard } from "react-use";
|
||||||
import { useEventSource } from "remix-utils/sse/react";
|
|
||||||
import { Alert } from "~/components/Alert";
|
import { Alert } from "~/components/Alert";
|
||||||
import { Divider } from "~/components/Divider";
|
import { Divider } from "~/components/Divider";
|
||||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||||
|
|
@ -16,15 +15,13 @@ import { EyeIcon } from "~/components/icons/Eye";
|
||||||
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
|
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
|
||||||
import { MapIcon } from "~/components/icons/Map";
|
import { MapIcon } from "~/components/icons/Map";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
|
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
|
||||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||||
|
import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||||
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
||||||
import {
|
import { SENDOU_INK_BASE_URL, tournamentJoinPage } from "~/utils/urls";
|
||||||
SENDOU_INK_BASE_URL,
|
|
||||||
tournamentBracketsSubscribePage,
|
|
||||||
tournamentJoinPage,
|
|
||||||
} from "~/utils/urls";
|
|
||||||
import {
|
import {
|
||||||
useBracketExpanded,
|
useBracketExpanded,
|
||||||
useTournament,
|
useTournament,
|
||||||
|
|
@ -36,11 +33,10 @@ import { BracketMapListDialog } from "../components/BracketMapListDialog";
|
||||||
import { TournamentTeamActions } from "../components/TournamentTeamActions";
|
import { TournamentTeamActions } from "../components/TournamentTeamActions";
|
||||||
import type { Bracket as BracketType } from "../core/Bracket";
|
import type { Bracket as BracketType } from "../core/Bracket";
|
||||||
import * as PreparedMaps from "../core/PreparedMaps";
|
import * as PreparedMaps from "../core/PreparedMaps";
|
||||||
import { bracketSubscriptionKey } from "../tournament-bracket-utils";
|
|
||||||
export { action };
|
export { action };
|
||||||
|
|
||||||
import "../components/Bracket/bracket.css";
|
|
||||||
import "../tournament-bracket.css";
|
import "../tournament-bracket.css";
|
||||||
|
import "../components/Bracket/bracket.css";
|
||||||
|
|
||||||
export default function TournamentBracketsPage() {
|
export default function TournamentBracketsPage() {
|
||||||
const { t } = useTranslation(["tournament"]);
|
const { t } = useTranslation(["tournament"]);
|
||||||
|
|
@ -51,6 +47,11 @@ export default function TournamentBracketsPage() {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
const ctx = useOutletContext();
|
const ctx = useOutletContext();
|
||||||
|
|
||||||
|
useWebsocketRevalidation({
|
||||||
|
room: tournamentWebsocketRoom(tournament.ctx.id),
|
||||||
|
connected: !tournament.ctx.isFinalized,
|
||||||
|
});
|
||||||
|
|
||||||
const defaultBracketIdx = () => {
|
const defaultBracketIdx = () => {
|
||||||
if (
|
if (
|
||||||
tournament.brackets.length === 1 ||
|
tournament.brackets.length === 1 ||
|
||||||
|
|
@ -74,10 +75,10 @@ export default function TournamentBracketsPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visibility !== "visible" || tournament.everyBracketOver) return;
|
if (visibility !== "visible" || tournament.ctx.isFinalized) return;
|
||||||
|
|
||||||
revalidate();
|
revalidate();
|
||||||
}, [visibility, revalidate, tournament.everyBracketOver]);
|
}, [visibility, revalidate, tournament.ctx.isFinalized]);
|
||||||
|
|
||||||
const showAddSubsButton =
|
const showAddSubsButton =
|
||||||
!tournament.canFinalize(user) &&
|
!tournament.canFinalize(user) &&
|
||||||
|
|
@ -160,9 +161,6 @@ export default function TournamentBracketsPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Outlet context={ctx} />
|
<Outlet context={ctx} />
|
||||||
{visibility !== "hidden" && !tournament.everyBracketOver ? (
|
|
||||||
<AutoRefresher />
|
|
||||||
) : null}
|
|
||||||
{tournament.canFinalize(user) ? (
|
{tournament.canFinalize(user) ? (
|
||||||
<div className="tournament-bracket__finalize">
|
<div className="tournament-bracket__finalize">
|
||||||
<LinkButton
|
<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({
|
function BracketStarter({
|
||||||
bracket,
|
bracket,
|
||||||
bracketIdx,
|
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 { useLoaderData, useRevalidator } from "@remix-run/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEventSource } from "remix-utils/sse/react";
|
|
||||||
import { LinkButton } from "~/components/elements/Button";
|
import { LinkButton } from "~/components/elements/Button";
|
||||||
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
|
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
|
||||||
import { containerClassName } from "~/components/Main";
|
import { containerClassName } from "~/components/Main";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
|
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
|
||||||
import { ConnectedChat } from "~/features/chat/components/Chat";
|
import { ConnectedChat } from "~/features/chat/components/Chat";
|
||||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||||
|
|
@ -13,10 +13,7 @@ import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||||
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
||||||
import invariant from "~/utils/invariant";
|
import invariant from "~/utils/invariant";
|
||||||
import { assertUnreachable } from "~/utils/types";
|
import { assertUnreachable } from "~/utils/types";
|
||||||
import {
|
import { tournamentBracketsPage } from "~/utils/urls";
|
||||||
tournamentBracketsPage,
|
|
||||||
tournamentMatchSubscribePage,
|
|
||||||
} from "~/utils/urls";
|
|
||||||
import { action } from "../actions/to.$id.matches.$mid.server";
|
import { action } from "../actions/to.$id.matches.$mid.server";
|
||||||
import { CastInfo } from "../components/CastInfo";
|
import { CastInfo } from "../components/CastInfo";
|
||||||
import { MatchRosters } from "../components/MatchRosters";
|
import { MatchRosters } from "../components/MatchRosters";
|
||||||
|
|
@ -26,7 +23,7 @@ import { getRounds } from "../core/rounds";
|
||||||
import { loader } from "../loaders/to.$id.matches.$mid.server";
|
import { loader } from "../loaders/to.$id.matches.$mid.server";
|
||||||
import {
|
import {
|
||||||
groupNumberToLetters,
|
groupNumberToLetters,
|
||||||
matchSubscriptionKey,
|
tournamentMatchWebsocketRoom,
|
||||||
} from "../tournament-bracket-utils";
|
} from "../tournament-bracket-utils";
|
||||||
export { action, loader };
|
export { action, loader };
|
||||||
|
|
||||||
|
|
@ -39,11 +36,16 @@ export default function TournamentMatchPage() {
|
||||||
const tournament = useTournament();
|
const tournament = useTournament();
|
||||||
const data = useLoaderData<typeof loader>();
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
useWebsocketRevalidation({
|
||||||
|
room: tournamentMatchWebsocketRoom(data.match.id),
|
||||||
|
connected: !tournament.ctx.isFinalized,
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visibility !== "visible" || data.matchIsOver) return;
|
if (visibility !== "visible" || tournament.ctx.isFinalized) return;
|
||||||
|
|
||||||
revalidate();
|
revalidate();
|
||||||
}, [visibility, revalidate, data.matchIsOver]);
|
}, [visibility, revalidate, tournament.ctx.isFinalized]);
|
||||||
|
|
||||||
const type =
|
const type =
|
||||||
tournament.canReportScore({ matchId: data.match.id, user }) ||
|
tournament.canReportScore({ matchId: data.match.id, user }) ||
|
||||||
|
|
@ -75,7 +77,6 @@ export default function TournamentMatchPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("stack lg", containerClassName("normal"))}>
|
<div className={clsx("stack lg", containerClassName("normal"))}>
|
||||||
{!data.matchIsOver && visibility !== "hidden" ? <AutoRefresher /> : null}
|
|
||||||
<div className="flex horizontal justify-between items-center">
|
<div className="flex horizontal justify-between items-center">
|
||||||
<MatchHeader />
|
<MatchHeader />
|
||||||
<div className="stack md horizontal flex-wrap-reverse justify-end">
|
<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({
|
function MapListSection({
|
||||||
teams,
|
teams,
|
||||||
type,
|
type,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ import type { Standing } from "./core/Bracket";
|
||||||
import type { Tournament } from "./core/Tournament";
|
import type { Tournament } from "./core/Tournament";
|
||||||
import type { TournamentDataTeam } from "./core/Tournament.server";
|
import type { TournamentDataTeam } from "./core/Tournament.server";
|
||||||
|
|
||||||
|
export const tournamentWebsocketRoom = (tournamentId: number) =>
|
||||||
|
`tournament__${tournamentId}`;
|
||||||
|
|
||||||
|
export const tournamentMatchWebsocketRoom = (matchId: number) =>
|
||||||
|
`match__${matchId}`;
|
||||||
|
|
||||||
const NUM_MAP = {
|
const NUM_MAP = {
|
||||||
"1": ["1", "2", "4"],
|
"1": ["1", "2", "4"],
|
||||||
"2": ["2", "1", "3", "5"],
|
"2": ["2", "1", "3", "5"],
|
||||||
|
|
@ -96,14 +102,6 @@ export function checkSourceIsValid({
|
||||||
return false;
|
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[]) {
|
export function fillWithNullTillPowerOfTwo<T>(arr: T[]) {
|
||||||
const nextPowerOfTwo = 2 ** Math.ceil(Math.log2(arr.length));
|
const nextPowerOfTwo = 2 ** Math.ceil(Math.log2(arr.length));
|
||||||
const nullsToAdd = nextPowerOfTwo - arr.length;
|
const nullsToAdd = nextPowerOfTwo - arr.length;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Main } from "~/components/Main";
|
import { Main } from "~/components/Main";
|
||||||
|
import { Placeholder } from "~/components/Placeholder";
|
||||||
import { SubNav, SubNavLink } from "~/components/SubNav";
|
import { SubNav, SubNavLink } from "~/components/SubNav";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||||
|
|
@ -32,7 +33,9 @@ import "../tournament.css";
|
||||||
|
|
||||||
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
|
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
|
||||||
const navigatedToMatchPage =
|
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;
|
if (navigatedToMatchPage) return false;
|
||||||
|
|
||||||
|
|
@ -101,7 +104,7 @@ export default function TournamentLayoutShell() {
|
||||||
if (!isMounted)
|
if (!isMounted)
|
||||||
return (
|
return (
|
||||||
<Main bigger>
|
<Main bigger>
|
||||||
<div className="tournament__placeholder" />
|
<Placeholder />
|
||||||
</Main>
|
</Main>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
.tournament__placeholder {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tournament__action-section {
|
.tournament__action-section {
|
||||||
padding: var(--s-0-5) var(--s-6) var(--s-6) var(--s-6);
|
padding: var(--s-0-5) var(--s-6) var(--s-6) var(--s-6);
|
||||||
border-radius: var(--rounded);
|
border-radius: var(--rounded);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import type {
|
||||||
TablesInsertable,
|
TablesInsertable,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
} from "~/db/tables";
|
} from "~/db/tables";
|
||||||
import type { ChatUser } from "~/features/chat/components/Chat";
|
|
||||||
import { userRoles } from "~/modules/permissions/mapper.server";
|
import { userRoles } from "~/modules/permissions/mapper.server";
|
||||||
import { isSupporter } from "~/modules/permissions/utils";
|
import { isSupporter } from "~/modules/permissions/utils";
|
||||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||||
|
|
@ -18,6 +17,7 @@ import invariant from "~/utils/invariant";
|
||||||
import type { CommonUser } from "~/utils/kysely.server";
|
import type { CommonUser } from "~/utils/kysely.server";
|
||||||
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
|
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
|
||||||
import { safeNumberParse } from "~/utils/number";
|
import { safeNumberParse } from "~/utils/number";
|
||||||
|
import type { ChatUser } from "../chat/chat-types";
|
||||||
|
|
||||||
const identifierToUserIdQuery = (identifier: string) =>
|
const identifierToUserIdQuery = (identifier: string) =>
|
||||||
db
|
db
|
||||||
|
|
|
||||||
|
|
@ -113,18 +113,10 @@ export default [
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
route(
|
|
||||||
"brackets/subscribe",
|
|
||||||
"features/tournament-bracket/routes/to.$id.brackets.subscribe.ts",
|
|
||||||
),
|
|
||||||
route(
|
route(
|
||||||
"matches/:mid",
|
"matches/:mid",
|
||||||
"features/tournament-bracket/routes/to.$id.matches.$mid.tsx",
|
"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"),
|
route("luti", "features/tournament/routes/luti.ts"),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -334,8 +334,6 @@ export const tournamentDivisionsPage = (tournamentId: number) =>
|
||||||
`/to/${tournamentId}/divisions`;
|
`/to/${tournamentId}/divisions`;
|
||||||
export const tournamentResultsPage = (tournamentId: number) =>
|
export const tournamentResultsPage = (tournamentId: number) =>
|
||||||
`/to/${tournamentId}/results`;
|
`/to/${tournamentId}/results`;
|
||||||
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
|
|
||||||
`/to/${tournamentId}/brackets/subscribe`;
|
|
||||||
export const tournamentMatchPage = ({
|
export const tournamentMatchPage = ({
|
||||||
tournamentId,
|
tournamentId,
|
||||||
matchId,
|
matchId,
|
||||||
|
|
@ -343,13 +341,6 @@ export const tournamentMatchPage = ({
|
||||||
tournamentId: number;
|
tournamentId: number;
|
||||||
matchId: number;
|
matchId: number;
|
||||||
}) => `/to/${tournamentId}/matches/${matchId}`;
|
}) => `/to/${tournamentId}/matches/${matchId}`;
|
||||||
export const tournamentMatchSubscribePage = ({
|
|
||||||
tournamentId,
|
|
||||||
matchId,
|
|
||||||
}: {
|
|
||||||
tournamentId: number;
|
|
||||||
matchId: number;
|
|
||||||
}) => `/to/${tournamentId}/matches/${matchId}/subscribe`;
|
|
||||||
export const tournamentJoinPage = ({
|
export const tournamentJoinPage = ({
|
||||||
tournamentId,
|
tournamentId,
|
||||||
inviteCode,
|
inviteCode,
|
||||||
|
|
|
||||||
|
|
@ -192,9 +192,32 @@ Cron jobs to perform actions on the server at certain intervals. To add a new on
|
||||||
|
|
||||||
### Real time
|
### 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
|
### Notifications
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@
|
||||||
"settings.sounds.likeReceived": "Like received",
|
"settings.sounds.likeReceived": "Like received",
|
||||||
"settings.sounds.groupNewMember": "Group new member",
|
"settings.sounds.groupNewMember": "Group new member",
|
||||||
"settings.sounds.matchStarted": "Match started",
|
"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.mapPool.notOk": "Pick {{count}} stages per mode that you didn't avoid to save your preferences",
|
||||||
"settings.misc.header": "Misc",
|
"settings.misc.header": "Misc",
|
||||||
"settings.banned": "Banned",
|
"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