diff --git a/app/components/Placeholder.module.css b/app/components/Placeholder.module.css new file mode 100644 index 000000000..2ba624aab --- /dev/null +++ b/app/components/Placeholder.module.css @@ -0,0 +1,3 @@ +.placeholder { + height: 100vh; +} diff --git a/app/components/Placeholder.tsx b/app/components/Placeholder.tsx new file mode 100644 index 000000000..d41c8da14 --- /dev/null +++ b/app/components/Placeholder.tsx @@ -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
; +} diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index 940cd8110..2a9440922 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -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 ; } return ; diff --git a/app/features/chat/chat-hooks.ts b/app/features/chat/chat-hooks.ts index 8552a1c15..1df882f35 100644 --- a/app/features/chat/chat-hooks.ts +++ b/app/features/chat/chat-hooks.ts @@ -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(); + const user = useUser(); + + const [messages, setMessages] = React.useState([]); + const [readyState, setReadyState] = React.useState< + "CONNECTING" | "CONNECTED" | "CLOSED" + >("CONNECTING"); + const [sentMessage, setSentMessage] = React.useState(); + const [currentRoom, setCurrentRoom] = React.useState( + rooms[0]?.code, + ); + + const ws = React.useRef(); + const lastSeenMessagesByRoomId = React.useRef>(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()); + } + + 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; +}) { + const lastUnseenEncountered = new Set(); + + 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()); +} diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 52a779016..413f4def6 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -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; + 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; +} diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index 26d1210f0..f5077030c 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -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; - 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 }) { ); } - -// 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(); - const user = useUser(); - - const [messages, setMessages] = React.useState([]); - const [readyState, setReadyState] = React.useState< - "CONNECTING" | "CONNECTED" | "CLOSED" - >("CONNECTING"); - const [sentMessage, setSentMessage] = React.useState(); - const [currentRoom, setCurrentRoom] = React.useState( - rooms[0]?.code, - ); - - const ws = React.useRef(); - const lastSeenMessagesByRoomId = React.useRef>(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()); - } - - 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; -}) { - const lastUnseenEncountered = new Set(); - - 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()); -} diff --git a/app/features/map-planner/plans.css b/app/features/map-planner/plans.css index c65fcce71..386c264af 100644 --- a/app/features/map-planner/plans.css +++ b/app/features/map-planner/plans.css @@ -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; diff --git a/app/features/map-planner/routes/plans.tsx b/app/features/map-planner/routes/plans.tsx index 8c4203cc0..64d60d241 100644 --- a/app/features/map-planner/routes/plans.tsx +++ b/app/features/map-planner/routes/plans.tsx @@ -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
; + if (!isMounted) return ; return ; } diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index fdaba9bef..38b7ff994 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -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"; diff --git a/app/features/sendouq-settings/routes/q.settings.tsx b/app/features/sendouq-settings/routes/q.settings.tsx index 9656b9cde..9fb736e12 100644 --- a/app/features/sendouq-settings/routes/q.settings.tsx +++ b/app/features/sendouq-settings/routes/q.settings.tsx @@ -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 diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index a9c877731..cc83fa843 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -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"; diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 1617fd17c..27d51d3b9 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -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; }; diff --git a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts index afcd3c21c..88a936a9e 100644 --- a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts @@ -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; }; diff --git a/app/features/tournament-bracket/components/StartedMatch.tsx b/app/features/tournament-bracket/components/StartedMatch.tsx index 155eae106..8d83f81b4 100644 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ b/app/features/tournament-bracket/components/StartedMatch.tsx @@ -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"; diff --git a/app/features/tournament-bracket/components/TournamentTeamActions.tsx b/app/features/tournament-bracket/components/TournamentTeamActions.tsx index 014dfaf95..b25298f37 100644 --- a/app/features/tournament-bracket/components/TournamentTeamActions.tsx +++ b/app/features/tournament-bracket/components/TournamentTeamActions.tsx @@ -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() { ); } + +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; +} diff --git a/app/features/tournament-bracket/core/emitters.server.ts b/app/features/tournament-bracket/core/emitters.server.ts deleted file mode 100644 index 4eac0bd6e..000000000 --- a/app/features/tournament-bracket/core/emitters.server.ts +++ /dev/null @@ -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; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.ts b/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.ts deleted file mode 100644 index 4f8ec49cb..000000000 --- a/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.ts +++ /dev/null @@ -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); - }; - }); -}; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index d42cd2ea2..ddc45f21e 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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 (
- {visibility !== "hidden" && !tournament.everyBracketOver ? ( - - ) : null} {tournament.canFinalize(user) ? (
{ - 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, diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.ts b/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.ts deleted file mode 100644 index 1b2ae2c0b..000000000 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.ts +++ /dev/null @@ -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); - }; - }); -}; diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index b8f0e8a88..e212ce8ea 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -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(); + 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 (
- {!data.matchIsOver && visibility !== "hidden" ? : null}
@@ -282,33 +283,6 @@ function MatchHeader() { ); } -function AutoRefresher() { - useAutoRefresh(); - - return null; -} - -function useAutoRefresh() { - const { revalidate } = useRevalidator(); - const tournament = useTournament(); - const data = useLoaderData(); - 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, diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 9370d0c49..d632d8ea6 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -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(arr: T[]) { const nextPowerOfTwo = 2 ** Math.ceil(Math.log2(arr.length)); const nullsToAdd = nextPowerOfTwo - arr.length; diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 02ae36b28..1cba9a592 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -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 (
-
+
); diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index f66843a74..7d6c4b5c2 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -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); diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 65597fbf6..be799888c 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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 diff --git a/app/routes.ts b/app/routes.ts index e12044579..8bdf43480 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/utils/urls.ts b/app/utils/urls.ts index a2d5e807a..cfde398b4 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.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, diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 2eab7db2f..6dcaa4be6 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -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 diff --git a/locales/en/q.json b/locales/en/q.json index f81d0bb3d..8e920d68c 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -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", diff --git a/public/static-assets/sounds/tournament_match.wav b/public/static-assets/sounds/tournament_match.wav new file mode 100644 index 000000000..28d596b79 Binary files /dev/null and b/public/static-assets/sounds/tournament_match.wav differ