mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Tournament match chat
This commit is contained in:
parent
0fb7da83d4
commit
cb7a8b4150
|
|
@ -26,6 +26,7 @@ export interface ChatProps {
|
|||
onMount?: () => void;
|
||||
onUnmount?: () => void;
|
||||
disabled?: boolean;
|
||||
missingUserName?: string;
|
||||
}
|
||||
|
||||
export function ConnectedChat(props: ChatProps) {
|
||||
|
|
@ -44,6 +45,7 @@ export function Chat({
|
|||
onMount,
|
||||
onUnmount,
|
||||
disabled,
|
||||
missingUserName,
|
||||
}: ChatProps & { chat: ReturnType<typeof useChat> }) {
|
||||
const messagesContainerRef = React.useRef<HTMLOListElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
|
@ -120,9 +122,16 @@ export function Chat({
|
|||
>
|
||||
{messages.map((msg) => {
|
||||
const user = msg.userId ? users[msg.userId] : null;
|
||||
if (!user) return null;
|
||||
if (!user && !missingUserName) return null;
|
||||
|
||||
return <Message key={msg.id} user={user} message={msg} />;
|
||||
return (
|
||||
<Message
|
||||
key={msg.id}
|
||||
user={user}
|
||||
missingUserName={missingUserName}
|
||||
message={msg}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<form onSubmit={handleSubmit} className="mt-4">
|
||||
|
|
@ -159,21 +168,29 @@ export function Chat({
|
|||
);
|
||||
}
|
||||
|
||||
function Message({ user, message }: { user: ChatUser; message: ChatMessage }) {
|
||||
function Message({
|
||||
user,
|
||||
message,
|
||||
missingUserName,
|
||||
}: {
|
||||
user?: ChatUser | null;
|
||||
message: ChatMessage;
|
||||
missingUserName?: string;
|
||||
}) {
|
||||
return (
|
||||
<li className="chat__message">
|
||||
<Avatar user={user} size="xs" />
|
||||
{user ? <Avatar user={user} size="xs" /> : null}
|
||||
<div>
|
||||
<div className="stack horizontal sm">
|
||||
<div
|
||||
className="chat__message__user"
|
||||
style={
|
||||
user.chatNameColor
|
||||
user?.chatNameColor
|
||||
? ({ "--chat-user-color": user.chatNameColor } as any)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{user.discordName}
|
||||
{user?.discordName ?? missingUserName}
|
||||
</div>
|
||||
{!message.pending ? (
|
||||
<time className="chat__message__time">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ interface NewTabsProps {
|
|||
scrolling?: boolean;
|
||||
selectedIndex?: number;
|
||||
setSelectedIndex?: (index: number) => void;
|
||||
/** Don't take space when no tabs to show? */
|
||||
disappearing?: boolean;
|
||||
}
|
||||
|
||||
export function NewTabs({
|
||||
|
|
@ -23,6 +25,7 @@ export function NewTabs({
|
|||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
disappearing = false,
|
||||
}: NewTabsProps) {
|
||||
const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1;
|
||||
|
||||
|
|
@ -31,14 +34,19 @@ export function NewTabs({
|
|||
<Tab.List
|
||||
className={clsx("tab__buttons-container", {
|
||||
"overflow-x-auto": scrolling,
|
||||
invisible: cantSwitchTabs,
|
||||
invisible: cantSwitchTabs && !disappearing,
|
||||
hidden: cantSwitchTabs && disappearing,
|
||||
})}
|
||||
>
|
||||
{tabs
|
||||
.filter((t) => !t.hidden)
|
||||
.map((tab) => {
|
||||
return (
|
||||
<Tab key={tab.label} className="tab__button">
|
||||
<Tab
|
||||
key={tab.label}
|
||||
className="tab__button"
|
||||
data-testid={`tab-${tab.label}`}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
<span className={clsx("tab__number")}>{tab.number}</span>
|
||||
|
|
|
|||
|
|
@ -321,6 +321,7 @@ export interface TournamentMatch {
|
|||
opponentOne: string; // json
|
||||
opponentTwo: string; // json
|
||||
status: Status;
|
||||
chatCode: string | null;
|
||||
}
|
||||
|
||||
export interface TournamentMatchGameResult {
|
||||
|
|
|
|||
|
|
@ -11,14 +11,13 @@ import { useTranslation } from "~/hooks/useTranslation";
|
|||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
||||
import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
|
||||
import { type TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
|
||||
import {
|
||||
HACKY_resolvePoolCode,
|
||||
mapCountPlayedInSetWithCertainty,
|
||||
resolveHostingTeam,
|
||||
resolveRoomPass,
|
||||
} from "../tournament-bracket-utils";
|
||||
import { ScoreReporterRosters } from "./ScoreReporterRosters";
|
||||
import type { SerializeFrom } from "@remix-run/node";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import type {
|
||||
|
|
@ -29,11 +28,16 @@ import { canAdminTournament } from "~/permissions";
|
|||
import { useUser } from "~/modules/auth";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { NewTabs } from "~/components/NewTabs";
|
||||
import { ScoreReporterRosters } from "./ScoreReporterRosters";
|
||||
import { Chat, useChat } from "~/components/Chat";
|
||||
import * as React from "react";
|
||||
|
||||
export type Result = Unpacked<
|
||||
SerializeFrom<TournamentMatchLoaderData>["results"]
|
||||
>;
|
||||
|
||||
// TODO: rename (since it now contains Chat as well)
|
||||
export function ScoreReporter({
|
||||
teams,
|
||||
currentStageWithMode,
|
||||
|
|
@ -44,12 +48,12 @@ export function ScoreReporter({
|
|||
type,
|
||||
}: {
|
||||
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
|
||||
result?: Result;
|
||||
currentStageWithMode: TournamentMapListMap;
|
||||
modes: ModeShort[];
|
||||
selectedResultIndex?: number;
|
||||
// if this is set it means the component is being used in presentation manner
|
||||
setSelectedResultIndex?: (index: number) => void;
|
||||
result?: Result;
|
||||
type: "EDIT" | "MEMBER" | "OTHER";
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
|
|
@ -173,20 +177,13 @@ export function ScoreReporter({
|
|||
setSelectedResultIndex={setSelectedResultIndex}
|
||||
/>
|
||||
{type === "EDIT" || presentational ? (
|
||||
<ActionSectionWrapper>
|
||||
<ScoreReporterRosters
|
||||
// Without the key prop when switching to another match the winnerId is remembered
|
||||
// which causes "No winning team matching the id" error.
|
||||
// Switching the key props forces the component to remount.
|
||||
key={data.match.id}
|
||||
scores={[scoreOne, scoreTwo]}
|
||||
teams={teams}
|
||||
position={currentPosition}
|
||||
currentStageWithMode={currentStageWithMode}
|
||||
result={result}
|
||||
bestOf={data.match.bestOf}
|
||||
/>
|
||||
</ActionSectionWrapper>
|
||||
<MatchActionSectionTabs
|
||||
presentational={presentational}
|
||||
scores={[scoreOne, scoreTwo]}
|
||||
currentStageWithMode={currentStageWithMode}
|
||||
teams={teams}
|
||||
result={result}
|
||||
/>
|
||||
) : null}
|
||||
{result ? (
|
||||
<div
|
||||
|
|
@ -336,6 +333,116 @@ function ModeProgressIndicator({
|
|||
);
|
||||
}
|
||||
|
||||
function MatchActionSectionTabs({
|
||||
presentational,
|
||||
scores,
|
||||
currentStageWithMode,
|
||||
teams,
|
||||
result,
|
||||
}: {
|
||||
presentational?: boolean;
|
||||
scores: [number, number];
|
||||
currentStageWithMode: TournamentMapListMap;
|
||||
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
|
||||
result?: Result;
|
||||
}) {
|
||||
const data = useLoaderData<TournamentMatchLoaderData>();
|
||||
const [_unseenMessages, setUnseenMessages] = React.useState(0);
|
||||
const [chatVisible, setChatVisible] = React.useState(false);
|
||||
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(data.match.players.map((p) => [p.id, p]));
|
||||
}, [data]);
|
||||
|
||||
const rooms = React.useMemo(() => {
|
||||
return data.match.chatCode
|
||||
? [
|
||||
{
|
||||
code: data.match.chatCode,
|
||||
label: "Match",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [data.match.chatCode]);
|
||||
|
||||
const onNewMessage = React.useCallback(() => {
|
||||
setUnseenMessages((msg) => msg + 1);
|
||||
}, []);
|
||||
|
||||
const chat = useChat({ rooms, onNewMessage });
|
||||
|
||||
const onChatMount = React.useCallback(() => {
|
||||
setChatVisible(true);
|
||||
}, []);
|
||||
|
||||
const onChatUnmount = React.useCallback(() => {
|
||||
setChatVisible(false);
|
||||
setUnseenMessages(0);
|
||||
}, []);
|
||||
|
||||
const unseenMessages = chatVisible ? 0 : _unseenMessages;
|
||||
|
||||
const currentPosition = scores[0] + scores[1];
|
||||
|
||||
return (
|
||||
<ActionSectionWrapper>
|
||||
<NewTabs
|
||||
tabs={[
|
||||
{
|
||||
label: "Chat",
|
||||
number: unseenMessages,
|
||||
hidden: !data.match.chatCode,
|
||||
},
|
||||
{
|
||||
label: presentational ? "Score" : "Report score",
|
||||
},
|
||||
]}
|
||||
disappearing
|
||||
content={[
|
||||
{
|
||||
key: "chat",
|
||||
hidden: !data.match.chatCode,
|
||||
element: (
|
||||
<>
|
||||
{data.match.chatCode ? (
|
||||
<Chat
|
||||
rooms={rooms}
|
||||
users={chatUsers}
|
||||
className="w-full q__chat-container"
|
||||
messagesContainerClassName="q__chat-messages-container"
|
||||
onNewMessage={onNewMessage}
|
||||
chat={chat}
|
||||
onMount={onChatMount}
|
||||
onUnmount={onChatUnmount}
|
||||
missingUserName="TO"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "report",
|
||||
element: (
|
||||
<ScoreReporterRosters
|
||||
// Without the key prop when switching to another match the winnerId is remembered
|
||||
// which causes "No winning team matching the id" error.
|
||||
// Switching the key props forces the component to remount.
|
||||
key={data.match.id}
|
||||
scores={scores}
|
||||
teams={teams}
|
||||
position={currentPosition}
|
||||
currentStageWithMode={currentStageWithMode}
|
||||
result={result}
|
||||
bestOf={data.match.bestOf}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ActionSectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionSectionWrapper({
|
||||
children,
|
||||
icon,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
TournamentStage,
|
||||
TournamentTeam,
|
||||
} from "~/db/types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const team_getByTournamentIdStm = sql.prepare(/*sql*/ `
|
||||
select
|
||||
|
|
@ -356,9 +357,9 @@ const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ `
|
|||
const match_insertStm = sql.prepare(/*sql*/ `
|
||||
insert into
|
||||
"TournamentMatch"
|
||||
("childCount", "roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status")
|
||||
("childCount", "roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode")
|
||||
values
|
||||
(@childCount, @roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status)
|
||||
(@childCount, @roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode)
|
||||
returning *
|
||||
`);
|
||||
|
||||
|
|
@ -458,6 +459,7 @@ export class Match {
|
|||
opponentOne: this.opponentOne,
|
||||
opponentTwo: this.opponentTwo,
|
||||
status: this.status,
|
||||
chatCode: nanoid(10),
|
||||
}) as any;
|
||||
|
||||
this.id = match.id;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
"TournamentMatch"."opponentOne",
|
||||
"TournamentMatch"."opponentTwo",
|
||||
"TournamentMatch"."bestOf",
|
||||
"TournamentMatch"."chatCode",
|
||||
"Tournament"."mapPickingStyle",
|
||||
json_group_array(
|
||||
json_object(
|
||||
|
|
@ -30,7 +31,8 @@ const stm = sql.prepare(/* sql */ `
|
|||
'customUrl',
|
||||
"User"."customUrl",
|
||||
'discordAvatar',
|
||||
"User"."discordAvatar"
|
||||
"User"."discordAvatar",
|
||||
'chatNameColor', IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)
|
||||
)
|
||||
) as "players"
|
||||
from "TournamentMatch"
|
||||
|
|
@ -49,7 +51,10 @@ export type FindMatchById = ReturnType<typeof findMatchById>;
|
|||
|
||||
export const findMatchById = (id: number) => {
|
||||
const row = stm.get({ id }) as
|
||||
| (Pick<TournamentMatch, "id" | "opponentOne" | "opponentTwo" | "bestOf"> &
|
||||
| (Pick<
|
||||
TournamentMatch,
|
||||
"id" | "opponentOne" | "opponentTwo" | "bestOf" | "chatCode"
|
||||
> &
|
||||
Pick<Tournament, "mapPickingStyle"> & { players: string })
|
||||
| undefined;
|
||||
|
||||
|
|
@ -67,6 +72,7 @@ export const findMatchById = (id: number) => {
|
|||
discordId: User["discordId"];
|
||||
customUrl: User["customUrl"];
|
||||
discordAvatar: User["discordAvatar"];
|
||||
chatNameColor: string | null;
|
||||
}>,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import {
|
|||
useOutletContext,
|
||||
useRevalidator,
|
||||
} from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as React from "react";
|
||||
import { useEventSource } from "remix-utils";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
|
||||
import { sql } from "~/db/sql";
|
||||
|
|
@ -23,6 +25,7 @@ import {
|
|||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
||||
import { requireUser, useUser } from "~/modules/auth";
|
||||
import { getUserId } from "~/modules/auth/user.server";
|
||||
import { canAdminTournament, canReportTournamentScore } from "~/permissions";
|
||||
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
|
@ -50,8 +53,6 @@ import {
|
|||
matchSubscriptionKey,
|
||||
} from "../tournament-bracket-utils";
|
||||
import bracketStyles from "../tournament-bracket.css";
|
||||
import clsx from "clsx";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{
|
||||
|
|
@ -279,7 +280,8 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
|
||||
export type TournamentMatchLoaderData = typeof loader;
|
||||
|
||||
export const loader = ({ params }: LoaderArgs) => {
|
||||
export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const tournamentId = tournamentIdFromParams(params);
|
||||
const matchId = matchIdFromParams(params);
|
||||
|
||||
|
|
@ -301,8 +303,18 @@ export const loader = ({ params }: LoaderArgs) => {
|
|||
|
||||
const currentMap = mapList?.[scoreSum];
|
||||
|
||||
const showChat =
|
||||
match.players.some((p) => p.id === user?.id) ||
|
||||
canAdminTournament({
|
||||
user,
|
||||
event: notFoundIfFalsy(findByIdentifier(tournamentId)),
|
||||
});
|
||||
|
||||
return {
|
||||
match,
|
||||
match: {
|
||||
...match,
|
||||
chatCode: showChat ? match.chatCode : null,
|
||||
},
|
||||
results: findResultsByMatchId(matchId),
|
||||
seeds: resolveSeeds(),
|
||||
currentMap,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.tournament__action-section {
|
||||
padding: var(--s-6);
|
||||
padding: var(--s-0-5) var(--s-6) var(--s-6) var(--s-6);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ const reportResult = async (
|
|||
sidesWithMoreThanFourPlayers: ("first" | "last")[] = ["last"],
|
||||
winner: 1 | 2 = 1,
|
||||
) => {
|
||||
await page.getByTestId("tab-Report score").click();
|
||||
|
||||
if (sidesWithMoreThanFourPlayers.includes("first")) {
|
||||
await page.getByTestId("player-checkbox-0").first().click();
|
||||
await page.getByTestId("player-checkbox-1").first().click();
|
||||
|
|
|
|||
7
migrations/039-tournament-match-chat-code.js
Normal file
7
migrations/039-tournament-match-chat-code.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module.exports.up = function (db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "TournamentMatch" add "chatCode" text`,
|
||||
).run();
|
||||
})();
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user