Tournament match chat

This commit is contained in:
Kalle 2023-10-12 18:45:38 +03:00
parent 0fb7da83d4
commit cb7a8b4150
10 changed files with 196 additions and 34 deletions

View File

@ -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">

View File

@ -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>

View File

@ -321,6 +321,7 @@ export interface TournamentMatch {
opponentOne: string; // json
opponentTwo: string; // json
status: Status;
chatCode: string | null;
}
export interface TournamentMatchGameResult {

View File

@ -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,

View File

@ -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;

View File

@ -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;
}>,
};
};

View File

@ -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,

View File

@ -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);
}

View File

@ -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();

View File

@ -0,0 +1,7 @@
module.exports.up = function (db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "TournamentMatch" add "chatCode" text`,
).run();
})();
};