SendouQ chat (#1508)

* Chat initial component

* Connect to websocket

* Chat render actual messages

* Chat keep scrolled to bottom

* Pending messages

* Pass rooms as arg

* Chat with tabs

* Message max length

* Looking tabs initial

* Fixes

* Chat on looking page w/ unread messages count

* Add reconnecting websocket

* Patron custom color

* Change ChatMessage data model

* Fix mobile layout

* Add clean up to useEffect

* Chat codes

* FF + clear messages on group morph

* Hide messages when user leaves etc.

* Fix match page layout when chat missing

* New tabs

* Tabs for mobile

* Add TODOs

* Switch to own group tab when roster changes

* Chat styling

* Redesign group cards

* Bring back manager buttons

* Remove flipped

* Remove unused code

* Align better

* Link to user profile

* Better room pass + highlighted

* Fix view when group expired

* Fix MemberAdder (missing input + overflow)

* Chat stay connected in looking

* Remove filters for now

* Fix chat number align

* Fix chat unseen messages

* Hide chat when alone

* Remove rest todos
This commit is contained in:
Kalle 2023-09-24 12:48:37 +03:00 committed by GitHub
parent 362f5d9a47
commit 0371bfcd12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1309 additions and 420 deletions

View File

@ -21,3 +21,7 @@ STORAGE_URL=
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
SKALOP_WS_URL=ws://localhost:5900
FF_ENABLE_CHAT=false

316
app/components/Chat.tsx Normal file
View File

@ -0,0 +1,316 @@
import { Avatar } from "./Avatar";
import * as React from "react";
import { SubmitButton } from "./SubmitButton";
import type { User } from "~/db/types";
import { useUser } from "~/modules/auth";
import { nanoid } from "nanoid";
import clsx from "clsx";
import { SKALOP_BASE_URL } from "~/utils/urls";
import { Button } from "./Button";
import ReconnectingWebSocket from "reconnecting-websocket";
import invariant from "tiny-invariant";
type ChatUser = Pick<User, "discordName" | "discordId" | "discordAvatar"> & {
chatNameColor: string | null;
};
const MESSAGE_MAX_LENGTH = 200;
export interface ChatProps {
users: Record<number, ChatUser>;
rooms: { label: string; code: string }[];
className?: string;
messagesContainerClassName?: string;
hidden?: boolean;
onNewMessage?: (message: ChatMessage) => void;
}
export function ConnectedChat(props: ChatProps) {
const chat = useChat(props);
return <Chat {...props} chat={chat} />;
}
export function Chat({
users,
rooms,
className,
messagesContainerClassName,
hidden = false,
chat,
}: ChatProps & { chat: ReturnType<typeof useChat> }) {
const messagesContainerRef = React.useRef<HTMLOListElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const {
send,
messages,
currentRoom,
setCurrentRoom,
connected,
unseenMessages,
} = chat;
const handleSubmit = React.useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
send(inputRef.current!.value);
inputRef.current!.value = "";
},
[send],
);
React.useEffect(() => {
const messagesContainer = messagesContainerRef.current!;
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}, [messages]);
return (
<section className={clsx("chat__container", className, { hidden })}>
{rooms.length > 1 ? (
<div className="stack horizontal">
{rooms.map((room, i) => {
const unseen = unseenMessages.get(room.code);
return (
<Button
key={room.code}
className={clsx("chat__room-button", {
"not-first": i > 0,
current: currentRoom === room.code,
})}
onClick={() => setCurrentRoom(room.code)}
>
<span className="chat__room-button__unseen invisible" />
{room.label}
{unseen ? (
<span className="chat__room-button__unseen">{unseen}</span>
) : (
<span className="chat__room-button__unseen invisible" />
)}
</Button>
);
})}
</div>
) : null}
<div className="chat__input-container">
<ol
className={clsx("chat__messages", messagesContainerClassName)}
ref={messagesContainerRef}
>
{messages.map((msg) => {
const user = msg.userId ? users[msg.userId] : null;
if (!user) return null;
return <Message key={msg.id} user={user} message={msg} />;
})}
</ol>
<form onSubmit={handleSubmit} className="mt-4">
<input
className="w-full"
ref={inputRef}
placeholder="Press enter to send"
disabled={!connected}
maxLength={MESSAGE_MAX_LENGTH}
/>{" "}
<div className="chat__bottom-row">
{typeof connected !== "boolean" ? (
<div />
) : connected ? (
<div className="text-xxs font-semi-bold text-lighter">
Connected
</div>
) : (
<div className="text-xxs font-semi-bold text-warning">
Disconnected
</div>
)}
<SubmitButton size="tiny" variant="minimal" disabled={!connected}>
Send
</SubmitButton>
</div>
</form>
</div>
</section>
);
}
function Message({ user, message }: { user: ChatUser; message: ChatMessage }) {
return (
<li className="chat__message">
<Avatar user={user} size="xs" className="my-auto" />
<div>
<div className="stack horizontal sm">
<div
className="chat__message__user"
style={
user.chatNameColor
? ({ "--chat-user-color": user.chatNameColor } as any)
: undefined
}
>
{user.discordName}
</div>
{!message.pending ? (
<time className="chat__message__time">
{new Date(message.timestamp).toLocaleTimeString()}
</time>
) : null}
</div>
<div
className={clsx("chat__message__contents", {
pending: message.pending,
})}
>
{message.contents}
</div>
</div>
</li>
);
}
// export type SystemMessageType = "MANAGER_ADDED" | "MANAGER_REMOVED";
export interface ChatMessage {
id: string;
// type?: SystemMessageType;
contents?: string;
// context?: any;
userId?: number;
timestamp: number;
room: string;
pending?: boolean;
}
export function useChat({
rooms,
onNewMessage,
}: {
rooms: ChatProps["rooms"];
onNewMessage?: (message: ChatMessage) => void;
}) {
const user = useUser();
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
const [connected, setConnected] = React.useState<null | boolean>(null);
const [sentMessage, setSentMessage] = React.useState<ChatMessage>();
const [currentRoom, setCurrentRoom] = React.useState<string | undefined>(
rooms[0]?.code,
);
const ws = React.useRef<ReconnectingWebSocket>();
const lastSeenMessagesByRoomId = React.useRef<Map<string, string>>(new Map());
React.useEffect(() => {
if (rooms.length === 0) return;
const url = `${SKALOP_BASE_URL}?${rooms
.map((room) => `room=${room.code}`)
.join("&")}`;
ws.current = new ReconnectingWebSocket(url, [], {
maxReconnectionDelay: 10000 * 2,
reconnectionDelayGrowFactor: 1.5,
});
ws.current.onopen = () => {
setCurrentRoom(rooms[0].code);
setConnected(true);
};
ws.current.onclose = () => setConnected(false);
ws.current.onmessage = (e) => {
const message = JSON.parse(e.data);
const messageArr = Array.isArray(message) ? message : [message];
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 {
onNewMessage?.(message);
setMessages((messages) => [...messages, ...messageArr]);
}
};
const wsCurrent = ws.current;
return () => {
wsCurrent?.close();
setMessages([]);
};
}, [rooms, onNewMessage]);
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,
connected,
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>());
}

View File

@ -3,17 +3,18 @@ import { Label } from "./Label";
import * as React from "react";
import { Button } from "./Button";
const EDITABLE_CSS_VARS = [
const CUSTOM_COLORS = [
"bg",
"bg-darker",
"bg-lighter",
"text",
"text-lighter",
"theme",
"chat",
] as const;
type CustomColorsRecord = Partial<
Record<(typeof EDITABLE_CSS_VARS)[number], string>
Record<(typeof CUSTOM_COLORS)[number], string>
>;
export function CustomizedColorsInput({
@ -31,7 +32,7 @@ export function CustomizedColorsInput({
<Label>{t("custom.colors.title")}</Label>
<input type="hidden" name="css" value={JSON.stringify(colors)} />
<div className="colors__grid">
{EDITABLE_CSS_VARS.map((cssVar) => {
{CUSTOM_COLORS.map((cssVar) => {
return (
<React.Fragment key={cssVar}>
<div>{t(`custom.colors.${cssVar}`)}</div>

View File

@ -146,7 +146,7 @@ export function TierImage({ tier, width = 200 }: TierImageProps) {
const height = width * 0.8675;
return (
<div className="tier__container">
<div className="tier__container" style={{ width }}>
<Image
path={tierImageUrl(tier.name)}
width={width}

View File

@ -0,0 +1,56 @@
import { Tab } from "@headlessui/react";
import clsx from "clsx";
interface NewTabsProps {
tabs: {
label: string;
number?: number;
hidden?: boolean;
}[];
content: {
key: string;
element: React.ReactNode;
hidden?: boolean;
}[];
scrolling?: boolean;
selectedIndex?: number;
setSelectedIndex?: (index: number) => void;
}
export function NewTabs({
tabs,
content,
scrolling = true,
selectedIndex,
setSelectedIndex,
}: NewTabsProps) {
return (
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
<Tab.List
className={clsx("tab__buttons-container", {
"overflow-x-auto": scrolling,
})}
>
{tabs
.filter((t) => !t.hidden)
.map((tab) => {
return (
<Tab key={tab.label} className="tab__button">
{tab.label}
{typeof tab.number === "number" && tab.number !== 0 && (
<span className={clsx("tab__number")}>{tab.number}</span>
)}
</Tab>
);
})}
</Tab.List>
<Tab.Panels className="mt-4">
{content
.filter((c) => !c.hidden)
.map((c) => {
return <Tab.Panel key={c.key}>{c.element}</Tab.Panel>;
})}
</Tab.Panels>
</Tab.Group>
);
}

View File

@ -0,0 +1,25 @@
import type { CSSProperties } from "react";
export function ChatIcon({
className,
style,
}: {
className?: string;
style?: CSSProperties;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
style={style}
>
<path
fillRule="evenodd"
d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97-1.94.284-3.916.455-5.922.505a.39.39 0 00-.266.112L8.78 21.53A.75.75 0 017.5 21v-3.955a48.842 48.842 0 01-2.652-.316c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@ -0,0 +1,18 @@
export function ClipboardIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z"
/>
</svg>
);
}

View File

@ -0,0 +1,16 @@
export function LinkIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path
fillRule="evenodd"
d="M19.902 4.098a3.75 3.75 0 00-5.304 0l-4.5 4.5a3.75 3.75 0 001.035 6.037.75.75 0 01-.646 1.353 5.25 5.25 0 01-1.449-8.45l4.5-4.5a5.25 5.25 0 117.424 7.424l-1.757 1.757a.75.75 0 11-1.06-1.06l1.757-1.757a3.75 3.75 0 000-5.304zm-7.389 4.267a.75.75 0 011-.353 5.25 5.25 0 011.449 8.45l-4.5 4.5a5.25 5.25 0 11-7.424-7.424l1.757-1.757a.75.75 0 111.06 1.06l-1.757 1.757a3.75 3.75 0 105.304 5.304l4.5-4.5a3.75 3.75 0 00-1.035-6.037.75.75 0 01-.354-1z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@ -1736,6 +1736,7 @@ function playedMatches() {
alphaGroupId: groupAlpha,
bravoGroupId: groupBravo,
mapList: randomMapList(groupAlpha, groupBravo),
addChatCode: false,
});
// update match createdAt to the past

View File

@ -526,6 +526,7 @@ export interface Group {
| "PREFER_ALL_MODES"
| "NO_PREFERENCE";
inviteCode: string;
chatCode: string | null;
status: "PREPARING" | "ACTIVE" | "INACTIVE";
}
@ -549,6 +550,7 @@ export interface GroupMatch {
createdAt: number;
reportedAt: number | null;
reportedByUserId: number | null;
chatCode: string | null;
}
export interface GroupMatchMap {

View File

@ -72,6 +72,11 @@ const PERKS = [
name: "discordColorRole",
extraInfo: true,
},
{
tier: 2,
name: "chatColor",
extraInfo: false,
},
{
tier: 2,
name: "seePlusPercentage",

View File

@ -1,183 +1,118 @@
import { Link, useFetcher } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { TierImage, WeaponImage } from "~/components/Image";
import { Image, TierImage, WeaponImage } from "~/components/Image";
import { Popover } from "~/components/Popover";
import { SubmitButton } from "~/components/SubmitButton";
import { ArrowsPointingInIcon } from "~/components/icons/ArrowsPointingIn";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import UndoIcon from "~/components/icons/Undo";
import { UsersIcon } from "~/components/icons/Users";
import { MicrophoneIcon } from "~/components/icons/Microphone";
import { SpeakerIcon } from "~/components/icons/Speaker";
import { SpeakerXIcon } from "~/components/icons/SpeakerX";
import type { Group, GroupMember as GroupMemberType } from "~/db/types";
import { SENDOUQ_LOOKING_PAGE, TIERS_PAGE, userPage } from "~/utils/urls";
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { languagesUnified } from "~/modules/i18n/config";
import { SENDOUQ_LOOKING_PAGE, navIconUrl, userPage } from "~/utils/urls";
import { FULL_GROUP_SIZE } from "../q-constants";
import type { LookingGroup } from "../q-types";
import { ModePreferenceIcons } from "./ModePrefenceIcons";
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
import { Popover } from "~/components/Popover";
import { SpeakerIcon } from "~/components/icons/Speaker";
import { MicrophoneIcon } from "~/components/icons/Microphone";
import { SpeakerXIcon } from "~/components/icons/SpeakerX";
import { useTranslation } from "~/hooks/useTranslation";
import { languagesUnified } from "~/modules/i18n/config";
import { useUser } from "~/modules/auth";
import { StarIcon } from "~/components/icons/Star";
import { StarFilledIcon } from "~/components/icons/StarFilled";
export function GroupCard({
group,
action,
mapListPreference,
ownRole,
ownGroup = false,
isExpired = false,
}: {
group: LookingGroup;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP";
mapListPreference?: Group["mapListPreference"];
ownRole?: GroupMemberType["role"];
ownGroup?: boolean;
isExpired?: boolean;
}) {
const fetcher = useFetcher();
return (
<Flipped flipId={group.id}>
<section className="q__group">
{mapListPreference ? (
<div className="stack lg horizontal justify-center">
<div className="stack xs horizontal items-center">
<ModePreferenceIcons preference={mapListPreference} />
</div>
</div>
) : null}
<div
className={clsx("stack sm", {
"horizontal justify-center": !group.members,
})}
>
{group.members?.map((member) => {
return (
<React.Fragment key={member.discordId}>
<GroupMember
member={member}
showActions={ownGroup && ownRole === "OWNER"}
/>
<div className="stack md horizontal items-center justify-between">
<div className="q__group-member-weapons">
{member.weapons?.map((weapon) => {
return (
<WeaponImage
key={weapon}
weaponSplId={weapon}
variant="badge"
size={36}
className="q__group-member-weapon"
/>
);
})}
</div>
{member.skill ? (
<Popover
buttonChildren={
<div className="text-xs font-bold text-lighter stack horizontal xxs items-center">
<TierImage tier={member.skill.tier} width={36} />
{!member.skill.approximate ? (
<>{ordinalToRoundedSp(member.skill.ordinal)}SP</>
) : null}
</div>
}
>
<div className="stack sm items-center">
<TierImage tier={member.skill.tier} width={100} />
<div>
{member.skill.tier.name}
{member.skill.tier.isPlus ? "+" : ""}
</div>
<Link
to={TIERS_PAGE}
className="text-xs"
target="_blank"
rel="noreferrer"
>
See all tiers
</Link>
</div>
</Popover>
) : null}
<section className="q__group">
<div
className={clsx("stack md", {
"horizontal justify-center": !group.members,
})}
>
{group.members?.map((member) => {
return (
<GroupMember
member={member}
showActions={ownGroup && ownRole === "OWNER"}
key={member.discordId}
/>
);
})}
{!group.members
? new Array(FULL_GROUP_SIZE).fill(null).map((_, i) => {
return (
<div key={i} className="q__member-placeholder">
?
</div>
</React.Fragment>
);
})}
{!group.members
? new Array(FULL_GROUP_SIZE).fill(null).map((_, i) => {
return (
<div key={i} className="q__member-placeholder">
?
</div>
);
})
: null}
</div>
{group.tier ? (
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
<TierImage tier={group.tier} width={100} />
<div>
{group.tier.name}
{group.tier.isPlus ? "+" : ""}{" "}
{group.isReplay ? (
<>
/ <span className="text-theme-secondary">REPLAY</span>
</>
) : null}
</div>
);
})
: null}
</div>
{group.tier ? (
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
<TierImage tier={group.tier} width={100} />
<div>
{group.tier.name}
{group.tier.isPlus ? "+" : ""}{" "}
{group.isReplay ? (
<>
/ <span className="text-theme-secondary">REPLAY</span>
</>
) : null}
</div>
) : null}
{action && (ownRole === "OWNER" || ownRole === "MANAGER") ? (
<fetcher.Form className="stack items-center" method="post">
<input type="hidden" name="targetGroupId" value={group.id} />
<SubmitButton
size="tiny"
variant={action === "UNLIKE" ? "destructive" : "outlined"}
_action={action}
state={fetcher.state}
icon={
action === "MATCH_UP" ? (
<ArrowsPointingInIcon />
) : action === "LIKE" ? (
<StarFilledIcon />
) : action === "GROUP_UP" ? (
<UsersIcon />
) : (
<UndoIcon />
)
}
>
{action === "MATCH_UP"
? "Start match"
: action === "LIKE" && !group.members
? "Challenge"
: action === "LIKE"
? "Ask to play"
: action === "GROUP_UP"
? "Group up"
: "Undo"}
</SubmitButton>
</fetcher.Form>
) : null}
{ownGroup ? (
<FormWithConfirm
dialogHeading="Leave this group?"
fields={[["_action", "LEAVE_GROUP"]]}
deleteButtonText="Leave"
action={SENDOUQ_LOOKING_PAGE}
</div>
) : null}
{action &&
(ownRole === "OWNER" || ownRole === "MANAGER") &&
!isExpired ? (
<fetcher.Form className="stack items-center" method="post">
<input type="hidden" name="targetGroupId" value={group.id} />
<SubmitButton
size="tiny"
variant={action === "UNLIKE" ? "destructive" : "outlined"}
_action={action}
state={fetcher.state}
>
<Button variant="minimal-destructive" size="tiny">
Leave group
</Button>
</FormWithConfirm>
) : null}
</section>
</Flipped>
{action === "MATCH_UP"
? "Start match"
: action === "LIKE" && !group.members
? "Challenge"
: action === "LIKE"
? "Invite"
: action === "GROUP_UP"
? "Group up"
: "Undo"}
</SubmitButton>
</fetcher.Form>
) : null}
{ownGroup ? (
<FormWithConfirm
dialogHeading="Leave this group?"
fields={[["_action", "LEAVE_GROUP"]]}
deleteButtonText="Leave"
action={SENDOUQ_LOOKING_PAGE}
>
<Button variant="minimal-destructive" size="tiny">
Leave group
</Button>
</FormWithConfirm>
) : null}
</section>
);
}
@ -187,45 +122,123 @@ function GroupMember({
}: {
member: NonNullable<LookingGroup["members"]>[number];
showActions: boolean;
}) {
return (
<div className="stack xxs">
<div className="q__group-member">
<Link
to={userPage(member)}
className="text-main-forced stack xs horizontal items-center"
target="_blank"
>
<Avatar user={member} size="xs" />
<span className="q__group-member__name">{member.discordName}</span>
</Link>
<div className="ml-auto stack horizontal sm items-center">
{showActions ? <MemberRoleManager member={member} /> : null}
{member.skill ? <TierInfo skill={member.skill} /> : null}
</div>
</div>
<div className="stack horizontal justify-between">
<div className="stack horizontal xxs">
{member.vc ? (
<div className="q__group-member__extra-info">
<VoiceChatInfo member={member} />
</div>
) : null}
{member.plusTier ? (
<div className="q__group-member__extra-info">
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />
{member.plusTier}
</div>
) : null}
</div>
{member.weapons ? (
<div className="q__group-member__extra-info">
{member.weapons?.map((weapon) => {
return (
<WeaponImage
key={weapon}
weaponSplId={weapon}
variant="badge"
size={26}
/>
);
})}
</div>
) : null}
</div>
</div>
);
}
function MemberRoleManager({
member,
}: {
member: NonNullable<LookingGroup["members"]>[number];
}) {
const fetcher = useFetcher();
const { t } = useTranslation(["q"]);
const Icon = member.role === "OWNER" ? StarFilledIcon : StarIcon;
return (
<fetcher.Form
className="stack sm horizontal items-center font-bold"
method="post"
action={SENDOUQ_LOOKING_PAGE}
<Popover
buttonChildren={
<Icon
className={clsx("q__group-member__star", {
"q__group-member__star__inactive": member.role === "REGULAR",
})}
/>
}
>
<input type="hidden" name="userId" value={member.id} />
<Link to={userPage(member)} className="q__group-member" target="_blank">
<Avatar user={member} size="xxs" />
{member.discordName}
</Link>
{member.plusTier ? (
<div className="text-xs text-lighter">+{member.plusTier}</div>
) : null}
{member.role === "REGULAR" && showActions ? (
<SubmitButton
variant="minimal"
size="tiny"
_action="GIVE_MANAGER"
state={fetcher.state}
>
Give manager
</SubmitButton>
) : null}
{member.role === "MANAGER" && showActions ? (
<SubmitButton
variant="minimal-destructive"
size="tiny"
_action="REMOVE_MANAGER"
state={fetcher.state}
>
Remove manager
</SubmitButton>
) : null}
<VoiceChatInfo member={member} />
</fetcher.Form>
<div className="stack md items-center">
<div>{t(`q:roles.${member.role}`)}</div>
{member.role !== "OWNER" ? (
<fetcher.Form method="post" action={SENDOUQ_LOOKING_PAGE}>
<input type="hidden" name="userId" value={member.id} />
{member.role === "REGULAR" ? (
<SubmitButton
variant="minimal"
size="tiny"
_action="GIVE_MANAGER"
state={fetcher.state}
>
Give manager
</SubmitButton>
) : null}
{member.role === "MANAGER" ? (
<SubmitButton
variant="minimal-destructive"
size="tiny"
_action="REMOVE_MANAGER"
state={fetcher.state}
>
Remove manager
</SubmitButton>
) : null}
</fetcher.Form>
) : null}
</div>
</Popover>
);
}
function TierInfo({ skill }: { skill: TieredSkill }) {
return (
<div className="q__group-member__tier">
<Popover buttonChildren={<TierImage tier={skill.tier} width={38} />}>
<div className="stack sm items-center">
<TierImage tier={skill.tier} width={80} />
<div className="text-lighter text-xxs">
{skill.tier.name}
{skill.tier.isPlus ? "+" : ""}
</div>
{!skill.approximate ? (
<> {ordinalToRoundedSp(skill.ordinal)}SP</>
) : null}
</div>
</Popover>
</div>
);
}

View File

@ -8,6 +8,9 @@ import {
sendouQInviteLink,
} from "~/utils/urls";
import * as React from "react";
import { ClipboardIcon } from "~/components/icons/Clipboard";
import { PlusIcon } from "~/components/icons/Plus";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
export function MemberAdder({
inviteCode,
@ -22,18 +25,28 @@ export function MemberAdder({
const [trustedUser, setTrustedUser] = React.useState<number>();
const fetcher = useFetcher();
const inviteLink = `${SENDOU_INK_BASE_URL}${sendouQInviteLink(inviteCode)}`;
const [, copyToClipboard] = useCopyToClipboard();
const [state, copyToClipboard] = useCopyToClipboard();
const [copySuccess, setCopySuccess] = React.useState(false);
const trustedPlayerIdsJoined = trustedPlayers.map((p) => p.id).join(",");
React.useEffect(() => {
setTrustedUser(undefined);
}, [trustedPlayerIdsJoined]);
React.useEffect(() => {
if (!state.value) return;
setCopySuccess(true);
const timeout = setTimeout(() => setCopySuccess(false), 2000);
return () => clearTimeout(timeout);
}, [state]);
return (
<div className="stack horizontal lg flex-wrap justify-center">
<div className="stack md flex-wrap justify-center">
{trustedPlayers.length > 0 ? (
<fetcher.Form method="post" action={SENDOUQ_PREPARING_PAGE}>
<label htmlFor="players">Add people you have played with</label>
<label htmlFor="players">Quick add</label>
<div className="stack horizontal sm items-center">
<select
name="id"
@ -43,8 +56,9 @@ export function MemberAdder({
e.target.value ? Number(e.target.value) : undefined,
)
}
className="q__member-adder__input"
>
<option value="">Select member</option>
<option value="">Select user</option>
{trustedPlayers.map((player) => {
return (
<option key={player.id} value={player.id}>
@ -57,22 +71,27 @@ export function MemberAdder({
variant="outlined"
_action="ADD_TRUSTED"
disabled={!trustedUser}
>
Add
</SubmitButton>
icon={<PlusIcon />}
/>
</div>
</fetcher.Form>
) : null}
<div>
<label htmlFor="invite">Share your invite link</label>
<label htmlFor="invite">Invite link</label>
<div className="stack horizontal sm items-center">
<input type="text" value={inviteLink} readOnly id="invite" />
<input
type="text"
value={inviteLink}
readOnly
id="invite"
className="q__member-adder__input"
/>
<Button
variant="outlined"
variant={copySuccess ? "outlined-success" : "outlined"}
onClick={() => copyToClipboard(inviteLink)}
>
Copy
</Button>
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
aria-label="Copy to clipboard"
/>
</div>
</div>
</div>

View File

@ -26,7 +26,6 @@ export function divideGroups({
let own: LookingGroupWithInviteCode | null = null;
const neutral: LookingGroupWithInviteCode[] = [];
const likesReceived: LookingGroupWithInviteCode[] = [];
const likesGiven: LookingGroupWithInviteCode[] = [];
const unneutralGroupIds = new Set<number>();
for (const like of likes) {
@ -45,9 +44,7 @@ export function divideGroups({
break;
}
if (like.targetGroupId === group.id) {
likesGiven.push(group);
unneutralGroupIds.add(group.id);
break;
group.isLiked = true;
}
}
}
@ -68,7 +65,6 @@ export function divideGroups({
return {
own,
neutral,
likesGiven,
likesReceived,
};
}
@ -132,7 +128,6 @@ export function addReplayIndicator({
return {
own: groups.own,
likesGiven: groups.likesGiven.map(addReplayIndicatorIfNeeded),
likesReceived: groups.likesReceived.map(addReplayIndicatorIfNeeded),
neutral: groups.neutral.map(addReplayIndicatorIfNeeded),
};
@ -164,9 +159,6 @@ export function censorGroups({
neutral: groups.neutral.map(
showMembers ? censorGroupPartly : censorGroupFully,
),
likesGiven: groups.likesGiven.map(
showMembers ? censorGroupPartly : censorGroupFully,
),
likesReceived: groups.likesReceived.map(
showMembers ? censorGroupPartly : censorGroupFully,
),
@ -214,7 +206,6 @@ export function addSkillsToGroups({
return {
own: addSkill(groups.own),
neutral: groups.neutral.map(addSkill),
likesGiven: groups.likesGiven.map(addSkill),
likesReceived: groups.likesReceived.map(addSkill),
};
}
@ -244,3 +235,12 @@ export function groupExpiryStatus(
return null;
}
export function hasAccessToChat(isByAdmin: boolean) {
const ff = process.env["FF_ENABLE_CHAT"];
if (ff === "true") return true;
if (ff === "admin") return isByAdmin;
return false;
}

View File

@ -7,17 +7,20 @@ export type LookingGroup = {
mapListPreference?: Group["mapListPreference"];
tier?: TieredSkill["tier"];
isReplay?: boolean;
isLiked?: boolean;
members?: {
id: number;
discordId: string;
discordName: string;
discordAvatar: string;
customUrl?: User["customUrl"];
plusTier?: PlusTier["tier"];
role: GroupMember["role"];
weapons?: MainWeaponId[];
skill?: TieredSkill;
vc?: User["vc"];
languages?: string[];
chatNameColor: string | null;
}[];
};
@ -30,12 +33,10 @@ export interface DividedGroups {
own: LookingGroup | LookingGroupWithInviteCode;
neutral: LookingGroup[];
likesReceived: LookingGroup[];
likesGiven: LookingGroup[];
}
export interface DividedGroupsUncensored {
own: LookingGroupWithInviteCode;
neutral: LookingGroupWithInviteCode[];
likesReceived: LookingGroupWithInviteCode[];
likesGiven: LookingGroupWithInviteCode[];
}

View File

@ -52,23 +52,77 @@
border-radius: 7px;
}
.q__own-group-container {
margin: 0 auto;
min-width: 300px;
.q__tab-button {
width: 100px;
background-color: var(--bg-lighter-solid);
color: var(--text);
font-size: var(--fonts-sm);
border-color: transparent;
gap: var(--s-2);
position: relative;
}
.q__tab-button__icon {
width: 14px;
}
.q__tab-button__badge {
font-size: var(--fonts-xxs);
display: grid;
place-items: center;
background-color: var(--theme);
position: absolute;
top: -4px;
right: -1px;
border-radius: 100%;
width: 17px;
height: 17px;
color: var(--button-text);
}
.q__top-container {
display: flex;
gap: var(--s-3);
max-width: 500px;
}
.q__top-container__divider {
min-width: 3px;
background-color: var(--border);
height: 100%;
border-radius: var(--rounded);
}
.q__chat-container {
top: 60px;
position: sticky;
}
.q__chat-messages-container {
height: 350px;
}
.q__groups-container {
display: grid;
justify-content: center;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--s-6);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--s-7);
}
.q__mobile-groups-container {
max-width: 300px;
.q__groups-container__mobile {
grid-template-columns: 1fr;
min-width: 300px;
margin: 0 auto;
}
.q__groups-inner-container {
max-width: 94vw;
}
.q__groups-container__right {
margin-top: 45px;
}
.q__group {
background-color: var(--bg-lighter-solid);
width: 100%;
@ -83,9 +137,33 @@
display: flex;
gap: var(--s-2);
align-items: center;
font-size: var(--fonts-xs);
background-color: var(--bg-darker);
border-radius: var(--rounded);
font-size: var(--fonts-xsm);
font-weight: var(--semi-bold);
padding-inline-end: var(--s-2-5);
}
.q__group-member__name {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.q__group-member__tier {
margin-inline-start: auto;
}
.q__group-member__extra-info {
font-size: var(--fonts-xs);
background-color: var(--bg-darker);
border-radius: var(--rounded);
padding: var(--s-0-5) var(--s-2);
width: max-content;
display: flex;
align-items: center;
gap: var(--s-1-5);
font-weight: var(--semi-bold);
color: var(--text);
}
.q__member-placeholder {
@ -112,10 +190,26 @@
}
.q__group-member-vc-icon {
width: 18px;
height: 15px;
stroke-width: 2;
}
.q__group-member__star {
min-width: 18px;
max-width: 18px;
color: var(--theme-secondary);
stroke-width: 2;
}
.q__group-member__star__inactive {
color: var(--text-lighter);
}
.q__member-adder__input {
--input-width: 11rem;
width: 11rem;
}
.q-preparing__card-container {
min-width: 250px;
margin: 0 auto;
@ -129,6 +223,13 @@
margin-block-start: var(--s-1);
}
.q-match__join-discord-section__highlighted {
font-size: var(--fonts-md);
letter-spacing: 1px;
color: var(--text);
font-weight: var(--semi-bold);
}
.q-match__members-container {
min-width: 180px;
}
@ -163,7 +264,11 @@
text-overflow: ellipsis;
}
@media screen and (min-width: 480px) {
@media screen and (min-width: 640px) {
.q-match__teams-container.with-chat {
grid-template-columns: 1fr 1fr 1fr;
}
.q-match__teams-container {
grid-template-columns: 1fr 1fr;
}

View File

@ -3,12 +3,14 @@ import { INVITE_CODE_LENGTH } from "~/constants";
import { sql } from "~/db/sql";
import type { Group, GroupMember } from "~/db/types";
import type { MapPool } from "~/modules/map-pool-serializer";
import { isAdmin } from "~/permissions";
import { hasAccessToChat } from "../core/groups.server";
const createGroupStm = sql.prepare(/* sql */ `
insert into "Group"
("mapListPreference", "inviteCode", "status")
("mapListPreference", "inviteCode", "status", "chatCode")
values
(@mapListPreference, @inviteCode, @status)
(@mapListPreference, @inviteCode, @status, @chatCode)
returning *
`);
@ -39,6 +41,9 @@ export const createGroup = sql.transaction((args: CreateGroupArgs) => {
mapListPreference: args.mapListPreference,
inviteCode: nanoid(INVITE_CODE_LENGTH),
status: args.status,
chatCode: hasAccessToChat(isAdmin({ id: args.userId }))
? nanoid(INVITE_CODE_LENGTH)
: null,
}) as Group;
createGroupMemberStm.run({
@ -68,11 +73,12 @@ type CreateGroupFromPreviousGroupArgs = {
const createGroupFromPreviousGroupStm = sql.prepare(/* sql */ `
insert into "Group"
("mapListPreference", "teamId", "inviteCode", "status")
("mapListPreference", "teamId", "chatCode", "inviteCode", "status")
values
(
(select "mapListPreference" from "Group" where "id" = @previousGroupId),
(select "teamId" from "Group" where "id" = @previousGroupId),
(select "chatCode" from "Group" where "id" = @previousGroupId),
@inviteCode,
@status
)

View File

@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import { sql } from "~/db/sql";
import type { GroupMatch } from "~/db/types";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
@ -6,10 +7,12 @@ import { syncGroupTeamId } from "./syncGroupTeamId.server";
const createMatchStm = sql.prepare(/* sql */ `
insert into "GroupMatch" (
"alphaGroupId",
"bravoGroupId"
"bravoGroupId",
"chatCode"
) values (
@alphaGroupId,
@bravoGroupId
@bravoGroupId,
@chatCode
)
returning *
`);
@ -35,14 +38,17 @@ export const createMatch = sql.transaction(
alphaGroupId,
bravoGroupId,
mapList,
addChatCode,
}: {
alphaGroupId: number;
bravoGroupId: number;
addChatCode: boolean;
mapList: TournamentMapListMap[];
}) => {
const match = createMatchStm.get({
alphaGroupId,
bravoGroupId,
chatCode: addChatCode ? nanoid(10) : null,
}) as GroupMatch;
for (const [i, { mode, source, stageId }] of mapList.entries()) {

View File

@ -7,6 +7,7 @@ const stm = sql.prepare(/* sql */ `
"Group"."id",
"Group"."status",
"Group"."latestActionAt",
"Group"."chatCode",
"GroupMatch"."id" as "matchId",
"GroupMember"."role"
from
@ -19,7 +20,10 @@ const stm = sql.prepare(/* sql */ `
and "GroupMember"."userId" = @userId
`);
type ActiveGroup = Pick<Group, "id" | "status" | "latestActionAt"> & {
type ActiveGroup = Pick<
Group,
"id" | "status" | "latestActionAt" | "chatCode"
> & {
matchId?: number;
role: GroupMember["role"];
};

View File

@ -10,6 +10,7 @@ const stm = sql.prepare(/* sql */ `
"GroupMatch"."createdAt",
"GroupMatch"."reportedAt",
"GroupMatch"."reportedByUserId",
"GroupMatch"."chatCode",
(select exists (select 1 from "Skill" where "Skill"."groupMatchId" = @id)) as "isLocked",
json_group_array(
json_object(
@ -34,6 +35,7 @@ export interface MatchById {
createdAt: GroupMatch["createdAt"];
reportedAt: GroupMatch["reportedAt"];
reportedByUserId: GroupMatch["reportedByUserId"];
chatCode: GroupMatch["chatCode"];
isLocked: number;
mapList: Array<
Pick<GroupMatchMap, "id" | "mode" | "stageId" | "source" | "winnerGroupId">

View File

@ -17,6 +17,7 @@ const stm = sql.prepare(/* sql */ `
)
select
"Group"."id",
"Group"."chatCode",
"AllTeam"."name" as "teamName",
"AllTeam"."customUrl" as "teamCustomUrl",
"UserSubmittedImage"."url" as "teamAvatarUrl",
@ -29,7 +30,8 @@ const stm = sql.prepare(/* sql */ `
'role', "GroupMemberWithWeapon"."role",
'customUrl', "User"."customUrl",
'inGameName', "User"."inGameName",
'weapons', "GroupMemberWithWeapon"."weapons"
'weapons', "GroupMemberWithWeapon"."weapons",
'chatNameColor', IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)
)
) as "members"
from
@ -46,6 +48,7 @@ const stm = sql.prepare(/* sql */ `
export interface GroupForMatch {
id: Group["id"];
chatCode: Group["chatCode"];
team?: {
name: string;
avatarUrl: string | null;
@ -60,6 +63,7 @@ export interface GroupForMatch {
customUrl: User["customUrl"];
inGameName: User["inGameName"];
weapons: Array<MainWeaponId>;
chatNameColor: string | null;
}>;
}
@ -69,6 +73,7 @@ export function groupForMatch(id: number) {
return {
id: row.id,
chatCode: row.chatCode,
team: row.teamName
? {
name: row.teamName,

View File

@ -17,7 +17,9 @@ const stm = sql.prepare(/* sql */ `
"User"."discordId",
"User"."discordName",
"User"."discordAvatar",
"User"."customUrl",
"User"."vc",
IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null) as "chatNameColor",
"User"."languages",
"PlusTier"."tier" as "plusTier",
"GroupMember"."role",
@ -49,6 +51,8 @@ const stm = sql.prepare(/* sql */ `
'discordId', "q1"."discordId",
'discordName', "q1"."discordName",
'discordAvatar', "q1"."discordAvatar",
'chatNameColor', "q1"."chatNameColor",
'customUrl', "q1"."customUrl",
'plusTier', "q1"."plusTier",
'role', "q1"."role",
'weapons', "q1"."weapons",

View File

@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import { sql } from "~/db/sql";
import { deleteLikesByGroupId } from "./deleteLikesByGroupId.server";
@ -16,21 +17,37 @@ const addGroupMemberStm = sql.prepare(/* sql */ `
values (@groupId, @userId, @role)
`);
const updateGroupStm = sql.prepare(/* sql */ `
update "Group"
set "chatCode" = @chatCode
where "id" = @groupId
`);
export const morphGroups = sql.transaction(
({
survivingGroupId,
otherGroupId,
newMembers,
addChatCode,
}: {
survivingGroupId: number;
otherGroupId: number;
newMembers: number[];
addChatCode: boolean;
}) => {
deleteGroupStm.run({ groupId: otherGroupId });
deleteGroupMapsStm.run({ groupId: otherGroupId });
deleteLikesByGroupId(survivingGroupId);
// reset chat code so previous messages are not visible
if (addChatCode) {
updateGroupStm.run({
groupId: survivingGroupId,
chatCode: nanoid(10),
});
}
for (const userId of newMembers) {
addGroupMemberStm.run({
groupId: survivingGroupId,

View File

@ -8,13 +8,12 @@ import { redirect } from "@remix-run/node";
import { useFetcher, useLoaderData, useSearchParams } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Flipper } from "react-flip-toolkit";
import invariant from "tiny-invariant";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTranslation } from "~/hooks/useTranslation";
import { getUserId, requireUserId } from "~/modules/auth/user.server";
import { getUser, requireUserId } from "~/modules/auth/user.server";
import { MapPool } from "~/modules/map-pool-serializer";
import {
parseRequestFormData,
@ -37,6 +36,7 @@ import {
divideGroups,
filterOutGroupsWithIncompatibleMapListPreference,
groupExpiryStatus,
hasAccessToChat,
membersNeededForFull,
} from "../core/groups.server";
import { matchMapList } from "../core/match.server";
@ -64,12 +64,14 @@ import { MemberAdder } from "../components/MemberAdder";
import type { LookingGroupWithInviteCode } from "../q-types";
import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.server";
import { userSkills } from "~/features/mmr/tiered.server";
import { useWindowSize } from "~/hooks/useWindowSize";
import { Tab, Tabs } from "~/components/Tabs";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { groupHasMatch } from "../queries/groupHasMatch.server";
import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server";
import { currentOrPreviousSeason } from "~/features/mmr/season";
import { Chat, useChat } from "~/components/Chat";
import { isAdmin } from "~/permissions";
import { NewTabs } from "~/components/NewTabs";
import { useWindowSize } from "~/hooks/useWindowSize";
export const handle: SendouRouteHandle = {
i18n: ["q"],
@ -170,6 +172,9 @@ export const action: ActionFunction = async ({ request }) => {
survivingGroupId,
otherGroupId: otherGroup.id,
newMembers: otherGroup.members.map((m) => m.id),
addChatCode: hasAccessToChat(
ourGroup.members.some(isAdmin) || theirGroup.members.some(isAdmin),
),
});
refreshGroup(survivingGroupId);
@ -218,6 +223,9 @@ export const action: ActionFunction = async ({ request }) => {
const createdMatch = createMatch({
alphaGroupId: ourGroup.id,
bravoGroupId: theirGroup.id,
addChatCode: hasAccessToChat(
ourGroup.members.some(isAdmin) || theirGroup.members.some(isAdmin),
),
mapList: matchMapList({
ourGroup,
theirGroup,
@ -280,7 +288,7 @@ export const action: ActionFunction = async ({ request }) => {
};
export const loader = async ({ request }: LoaderArgs) => {
const user = await getUserId(request);
const user = await getUser(request);
const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
const redirectLocation = groupRedirectLocationByCurrentLocation({
@ -337,6 +345,9 @@ export const loader = async ({ request }: LoaderArgs) => {
return {
groups: censoredGroups,
role: currentGroup.role,
chatCode:
// don't chat with yourself...
censoredGroups.own.members!.length > 1 ? currentGroup.chatCode : null,
lastUpdated: new Date().getTime(),
expiryStatus: groupExpiryStatus(currentGroup),
trustedPlayers: hasGroupManagerPerms(currentGroup.role)
@ -346,33 +357,14 @@ export const loader = async ({ request }: LoaderArgs) => {
};
export default function QLookingPage() {
const data = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
useAutoRefresh();
const ownGroup = data.groups.own as LookingGroupWithInviteCode;
const wasTryingToJoinAnotherTeam = searchParams.get("joining") === "true";
return (
<Main className="stack lg">
<div className="stack sm">
<InfoText />
<div className="q__own-group-container">
<GroupCard
group={data.groups.own}
mapListPreference={data.groups.own.mapListPreference}
ownRole={data.role}
ownGroup
/>
</div>
</div>
{ownGroup.inviteCode ? (
<MemberAdder
inviteCode={ownGroup.inviteCode}
trustedPlayers={data.trustedPlayers}
/>
) : null}
<Main className="stack md">
<InfoText />
{wasTryingToJoinAnotherTeam ? (
<div className="text-warning text-center">
Before joining another group, leave the current one
@ -414,7 +406,7 @@ function InfoText() {
method="post"
className="text-xs text-lighter ml-auto text-warning stack horizontal sm"
>
Group will be hidden soon due to inactivity. Still looking?{" "}
Group will be marked inactive. Still looking?{" "}
<SubmitButton
size="tiny"
variant="minimal"
@ -445,159 +437,220 @@ function InfoText() {
function Groups() {
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [_unseenMessages, setUnseenMessages] = React.useState(0);
const [selectedIndex, setSelectedIndex] = React.useState(1);
const { width } = useWindowSize();
if (data.expiryStatus === "EXPIRED" || !isMounted) return null;
const chatUsers = React.useMemo(() => {
return Object.fromEntries(data.groups.own.members!.map((m) => [m.id, m]));
}, [data]);
if (width < 750) return <MobileGroupCards />;
return <GroupCardColumns />;
}
const rooms = React.useMemo(() => {
return data.chatCode
? [
{
code: data.chatCode,
label: "Group",
},
]
: [];
}, [data.chatCode]);
function MobileGroupCards() {
const data = useLoaderData<typeof loader>();
const [tab, setTab] = React.useState<"received" | "neutral" | "given">(
"neutral",
const onNewMessage = React.useCallback(() => {
setUnseenMessages((msg) => msg + 1);
}, []);
const chat = useChat({ rooms, onNewMessage });
// reset to own group tab when the roster changes
const memberIdsJoined = data.groups.own.members
?.map((m) => m.id)
.sort((a, b) => a - b)
.join(",");
React.useEffect(() => {
if (memberIdsJoined && memberIdsJoined.split(",").length === 1) return;
setSelectedIndex(0);
}, [memberIdsJoined]);
const CHAT_TAB_INDEX = 3;
// the way of doing it seems a bit backwards but trying to work
// around React dep arrays triggering unintended effects from
// running in the Chat component
const handleTabChange = React.useCallback(
(newIndex: number) => {
const currentTabIndex = selectedIndex;
if (currentTabIndex === CHAT_TAB_INDEX) {
setUnseenMessages(0);
}
setSelectedIndex(newIndex);
},
[selectedIndex],
);
const unseenMessages = selectedIndex !== CHAT_TAB_INDEX ? _unseenMessages : 0;
if (!isMounted) return null;
const isMobile = width < 750;
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
const ownGroup = data.groups.own as LookingGroupWithInviteCode;
const chatElement = (
<div>
{data.chatCode ? (
<Chat
rooms={rooms}
users={chatUsers}
className="w-full q__chat-container"
messagesContainerClassName="q__chat-messages-container"
onNewMessage={onNewMessage}
chat={chat}
/>
) : null}
</div>
);
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
const groups =
tab === "received"
? data.groups.likesReceived
: tab === "given"
? data.groups.likesGiven
: data.groups.neutral;
return (
<div className="mt-6">
<Tabs compact>
<Tab active={tab === "received"} onClick={() => setTab("received")}>
Received ({data.groups.likesReceived.length})
</Tab>
<Tab active={tab === "neutral"} onClick={() => setTab("neutral")}>
Neutral ({data.groups.neutral.length})
</Tab>
<Tab active={tab === "given"} onClick={() => setTab("given")}>
Given ({data.groups.likesGiven.length})
</Tab>
</Tabs>
<div className="stack sm q__mobile-groups-container">
{groups.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: tab === "received" ? "THEM" : "US",
ourGroup: data.groups.own,
theirGroup: group,
});
<div
className={clsx("q__groups-container", {
"q__groups-container__mobile": isMobile,
})}
>
{!isMobile ? chatElement : null}
<div className="q__groups-inner-container">
<NewTabs
scrolling={isMobile}
selectedIndex={selectedIndex}
setSelectedIndex={handleTabChange}
tabs={[
{
label: "Roster",
number: data.groups.own.members!.length,
},
{
label: "Groups",
number: data.groups.neutral.length,
},
{
label: isFullGroup ? "Challenges" : "Invitations",
number: data.groups.likesReceived.length,
hidden: !isMobile,
},
{
label: "Chat",
hidden: !isMobile || !data.chatCode,
number: unseenMessages,
},
// {
// label: "Filter",
// },
]}
content={[
{
key: "own",
element: (
<div className="stack md">
<GroupCard
group={data.groups.own}
mapListPreference={data.groups.own.mapListPreference}
ownRole={data.role}
ownGroup
/>
{ownGroup.inviteCode ? (
<MemberAdder
inviteCode={ownGroup.inviteCode}
trustedPlayers={data.trustedPlayers}
/>
) : null}
</div>
),
},
{
key: "groups",
element: (
<div className="stack sm">
{data.groups.neutral.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "US",
ourGroup: data.groups.own,
theirGroup: group,
});
const action =
tab === "neutral"
? "LIKE"
: tab === "given"
? "UNLIKE"
: isFullGroup
? "MATCH_UP"
: "GROUP_UP";
return (
<GroupCard
key={group.id}
group={group}
action={group.isLiked ? "UNLIKE" : "LIKE"}
mapListPreference={mapListPreference}
ownRole={data.role}
isExpired={data.expiryStatus === "EXPIRED"}
/>
);
})}
</div>
),
},
{
key: "received",
hidden: !isMobile,
element: (
<div className="stack sm">
{data.groups.likesReceived.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "THEM",
ourGroup: data.groups.own,
theirGroup: group,
});
return (
<GroupCard
key={group.id}
group={group}
action={action}
mapListPreference={mapListPreference}
ownRole={data.role}
/>
);
})}
return (
<GroupCard
key={group.id}
group={group}
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
mapListPreference={mapListPreference}
ownRole={data.role}
isExpired={data.expiryStatus === "EXPIRED"}
/>
);
})}
</div>
),
},
{
key: "chat",
element: chatElement,
hidden: !isMobile || !data.chatCode,
},
// {
// key: "filters",
// element: <div>filters</div>,
// },
]}
/>
</div>
{!isMobile ? (
<div className="stack sm q__groups-container__right">
{data.groups.likesReceived.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "THEM",
ourGroup: data.groups.own,
theirGroup: group,
});
return (
<GroupCard
key={group.id}
group={group}
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
mapListPreference={mapListPreference}
ownRole={data.role}
isExpired={data.expiryStatus === "EXPIRED"}
/>
);
})}
</div>
) : null}
</div>
);
}
function GroupCardColumns() {
const data = useLoaderData<typeof loader>();
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
return (
<Flipper
flipKey={`${data.groups.likesReceived
.map((g) => g.id)
.join("")}-${data.groups.neutral
.map((g) => g.id)
.join("")}-${data.groups.likesGiven.map((g) => g.id).join("")}`}
>
<div className="q__groups-container">
<div>
<h2 className="text-sm text-center mb-2">
{isFullGroup ? "Challenges received" : "Groups that asked you"}
</h2>
<div className="stack sm">
{data.groups.likesReceived.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "THEM",
ourGroup: data.groups.own,
theirGroup: group,
});
return (
<GroupCard
key={group.id}
group={group}
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
mapListPreference={mapListPreference}
ownRole={data.role}
/>
);
})}
</div>
</div>
<div className="w-full">
<h2 className="text-sm text-center mb-2 invisible">Neutral</h2>
<div className="stack sm">
{data.groups.neutral.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "US",
ourGroup: data.groups.own,
theirGroup: group,
});
return (
<GroupCard
key={group.id}
group={group}
action="LIKE"
mapListPreference={mapListPreference}
ownRole={data.role}
/>
);
})}
</div>
</div>
<div>
<h2 className="text-sm text-center mb-2">
{isFullGroup ? "Challenges issued" : "Groups you asked"}
</h2>
<div className="stack sm">
{data.groups.likesGiven.map((group) => {
const { mapListPreference } = groupAfterMorph({
liker: "US",
ourGroup: data.groups.own,
theirGroup: group,
});
return (
<GroupCard
key={group.id}
group={group}
action="UNLIKE"
mapListPreference={mapListPreference}
ownRole={data.role}
/>
);
})}
</div>
</div>
</div>
</Flipper>
);
}

View File

@ -24,7 +24,7 @@ import type { GroupMember, ReportedWeapon } from "~/db/types";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { requireUserId } from "~/modules/auth/user.server";
import { getUserId, requireUserId } from "~/modules/auth/user.server";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { isAdmin } from "~/permissions";
import { databaseTimestampToDate } from "~/utils/dates";
@ -79,6 +79,7 @@ import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracke
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { addDummySkill } from "../queries/addDummySkill.server";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import { ConnectedChat, type ChatProps } from "~/components/Chat";
import { currentSeason } from "~/features/mmr";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import { StarIcon } from "~/components/icons/Star";
@ -280,7 +281,8 @@ export const action = async ({ request, params }: ActionArgs) => {
return null;
};
export const loader = ({ params }: LoaderArgs) => {
export const loader = async ({ params, request }: LoaderArgs) => {
const user = await getUserId(request);
const matchId = matchIdFromParams(params);
const match = notFoundIfFalsy(findMatchById(matchId));
@ -289,10 +291,27 @@ export const loader = ({ params }: LoaderArgs) => {
const groupBravo = groupForMatch(match.bravoGroupId);
invariant(groupBravo, "Group bravo not found");
const censoredGroupAlpha = { ...groupAlpha, chatCode: undefined };
const censoredGroupBravo = { ...groupBravo, chatCode: undefined };
const censoredMatch = { ...match, chatCode: undefined };
const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id);
const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id);
const canAccessMatchChat = isTeamAlphaMember || isTeamBravoMember;
const groupChatCode = () => {
if (isTeamAlphaMember) return groupAlpha.chatCode;
if (isTeamBravoMember) return groupBravo.chatCode;
return null;
};
return {
match,
groupAlpha,
groupBravo,
match: censoredMatch,
matchChatCode: canAccessMatchChat ? match.chatCode : null,
groupChatCode: groupChatCode(),
groupAlpha: censoredGroupAlpha,
groupBravo: censoredGroupBravo,
reportedWeapons: match.reportedAt
? reportedWeaponsByMatchId(matchId)
: undefined,
@ -338,6 +357,22 @@ export default function QMatchPage() {
return `SQ${lastDigit}`;
};
const chatUsers = React.useMemo(() => {
return Object.fromEntries(
[...data.groupAlpha.members, ...data.groupBravo.members].map((m) => [
m.id,
m,
]),
);
}, [data]);
const chatRooms = React.useMemo(() => {
return [
data.matchChatCode ? { code: data.matchChatCode, label: `Match` } : null,
data.groupChatCode ? { code: data.groupChatCode, label: "Group" } : null,
].filter(Boolean) as ChatProps["rooms"];
}, [data.matchChatCode, data.groupChatCode]);
return (
<Main className="q-match__container stack lg">
<div className="q-match__header">
@ -379,7 +414,11 @@ export default function QMatchPage() {
) : null}
{!showWeaponsForm ? (
<>
<div className="q-match__teams-container">
<div
className={clsx("q-match__teams-container", {
"with-chat": data.matchChatCode || data.groupChatCode,
})}
>
<MatchGroup
group={data.groupAlpha}
side="ALPHA"
@ -390,8 +429,11 @@ export default function QMatchPage() {
side="BRAVO"
showWeapons={!data.match.isLocked}
/>
{chatRooms.length > 0 ? (
<ConnectedChat users={chatUsers} rooms={chatRooms} />
) : null}
</div>
{!data.match.isLocked && ownMember ? (
{!data.match.isLocked && (ownMember || isAdmin(user)) ? (
<div>
<div className="stack horizontal justify-between">
<Link to={SENDOUQ_RULES_PAGE} className="text-xxs font-bold">
@ -433,8 +475,13 @@ export default function QMatchPage() {
{SENDOU_INK_DISCORD_URL}
</a>
. Alpha team hosts. Password should be{" "}
<b>{resolveRoomPass(data.match.id)}</b>. Pool code is{" "}
<b>{poolCode()}</b>
<span className="q-match__join-discord-section__highlighted">
{resolveRoomPass(data.match.id)}
</span>
. Pool code is{" "}
<span className="q-match__join-discord-section__highlighted">
{poolCode()}
</span>
</div>
</div>
) : null}
@ -773,7 +820,7 @@ function MatchGroup({
side,
showWeapons,
}: {
group: GroupForMatch;
group: Omit<GroupForMatch, "chatCode">;
side: "ALPHA" | "BRAVO";
showWeapons: boolean;
}) {

View File

@ -25,17 +25,29 @@ export function matchIdFromParams(params: Params<string>) {
return result;
}
const passNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const NUM_MAP = {
"1": ["1", "2", "4"],
"2": ["2", "1", "3", "5"],
"3": ["3", "2", "6"],
"4": ["4", "1", "5", "7"],
"5": ["5", "2", "4", "6", "8"],
"6": ["6", "3", "5", "9"],
"7": ["7", "4", "8"],
"8": ["8", "7", "5", "9", "0"],
"9": ["9", "6", "8"],
"0": ["0", "8"],
};
export function resolveRoomPass(matchId: TournamentMatch["id"]) {
let result = "";
for (let i = 0; i < 4; i++) {
let pass = "5";
for (let i = 0; i < 3; i++) {
const { shuffle } = seededRandom(`${matchId}-${i}`);
result += shuffle(passNumbers)[0];
const key = pass[i] as keyof typeof NUM_MAP;
const opts = NUM_MAP[key];
const next = shuffle(opts)[0];
pass += next;
}
return result;
return pass;
}
export function resolveHostingTeam(

View File

@ -530,6 +530,42 @@ dialog::backdrop {
visibility: initial;
}
.tab__buttons-container {
display: flex;
flex-direction: row;
border-bottom: 2px solid var(--border);
}
.tab__button {
background-color: transparent;
border: none;
font-size: var(--fonts-xs);
border-radius: 0;
border-bottom: 2px solid transparent;
color: var(--text-lighter);
white-space: nowrap;
flex: 1;
}
.tab__button:active {
transform: initial;
}
.tab__number {
color: var(--theme);
margin-inline-start: var(--s-2);
}
.tab__button:focus-visible {
color: var(--theme) !important;
outline: none;
}
.tab__button[data-headlessui-state="selected"] {
border-color: var(--theme);
color: var(--text);
}
.popover-content {
z-index: 1;
max-width: 20rem;
@ -1279,6 +1315,87 @@ dialog::backdrop {
border-radius: var(--rounded);
}
.chat__container {
display: flex;
flex-direction: column;
}
.chat__messages {
padding: var(--s-2) 0 0 0;
display: flex;
flex-direction: column;
gap: var(--s-2);
height: 270px;
overflow-y: auto;
}
.chat__message {
list-style: none;
display: flex;
gap: var(--s-2-5);
}
.chat__message__user {
font-weight: var(--semi-bold);
font-size: var(--fonts-sm);
color: var(--chat-user-color);
}
.chat__message__time {
font-size: var(--fonts-xxs);
color: var(--text-lighter);
margin-block-start: 3px;
}
.chat__input-container {
margin-top: auto;
--input-width: 100%;
}
.chat__message__contents {
font-size: var(--fonts-sm);
word-break: break-all;
}
.chat__message__contents.pending {
opacity: 0.7;
}
.chat__room-button {
background-color: var(--bg-light);
color: var(--text-lighter);
border-radius: var(--rounded) var(--rounded) 0 0;
font-size: var(--fonts-xs);
padding: var(--s-1) var(--s-1);
border-color: var(--bg-light-variation);
}
.chat__room-button.not-first {
margin-inline-start: -1px;
}
.chat__room-button.current {
background-color: var(--bg-lighter);
color: var(--text);
}
.chat__room-button__unseen {
color: var(--theme-secondary);
text-shadow: var(--fonts-xxxs);
margin-inline-start: var(--s-1);
width: 25px;
text-align: left;
}
.chat__bottom-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-block-start: var(--s-2);
height: 20px;
}
.winner__container {
height: 125px;
width: 125px;

View File

@ -234,6 +234,10 @@
margin-inline: var(--s-2);
}
.my-auto {
margin-block: auto;
}
.my-2 {
margin-block: var(--s-2);
}
@ -266,6 +270,10 @@
white-space: nowrap;
}
.overflow-x-auto {
overflow-x: auto;
}
.flex {
display: flex;
}

View File

@ -47,6 +47,7 @@ html {
--fonts-lg: 1.2rem;
--fonts-md: 1rem;
--fonts-sm: 0.92rem;
--fonts-xsm: 0.85rem;
--fonts-xs: 0.8rem;
--fonts-xxs: 0.7rem;
--fonts-xxxs: 0.6rem;

View File

@ -37,6 +37,8 @@ const staticAssetsUrl = ({
}) =>
`https://raw.githubusercontent.com/Sendouc/sendou-ink-assets/main/${folder}/${fileName}`;
export const SKALOP_BASE_URL = "ws://localhost:5900";
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
const USER_SUBMITTED_IMAGE_ROOT = "https://sendou.nyc3.digitaloceanspaces.com";

View File

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

11
package-lock.json generated
View File

@ -50,6 +50,7 @@
"react-popper": "^2.3.0",
"react-responsive-masonry": "^2.1.7",
"react-use": "^17.4.0",
"reconnecting-websocket": "^4.4.0",
"remix-auth": "^3.5.1",
"remix-auth-oauth2": "^1.8.0",
"remix-i18next": "^4.1.1",
@ -12688,6 +12689,11 @@
"node": ">=8.10.0"
}
},
"node_modules/reconnecting-websocket": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz",
"integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng=="
},
"node_modules/redent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",
@ -24245,6 +24251,11 @@
"picomatch": "^2.2.1"
}
},
"reconnecting-websocket": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz",
"integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng=="
},
"redent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",

View File

@ -83,6 +83,7 @@
"react-popper": "^2.3.0",
"react-responsive-masonry": "^2.1.7",
"react-use": "^17.4.0",
"reconnecting-websocket": "^4.4.0",
"remix-auth": "^3.5.1",
"remix-auth-oauth2": "^1.8.0",
"remix-i18next": "^4.1.1",

View File

@ -159,6 +159,7 @@
"support.perk.nameInFooter": "Name in the footer",
"support.perk.discordColorRole": "Discord color role",
"support.perk.discordColorRole.extra": "You can have a custom color role on Plus Server and sendou.ink's Discord server. Use the /color command of the Lohi bot.",
"support.perk.chatColor": "Choose name color in the sendou.ink chat",
"support.perk.badge": "Profile badge",
"support.perk.seePlusPercentage": "See Plus Server voting percentage",
"support.perk.seePlusPercentage.extra": "Normally only failed suggests see their voting percentage. With this, you can always see your own percentage.",
@ -179,6 +180,7 @@
"custom.colors.text": "Text",
"custom.colors.text-lighter": "Text lighter",
"custom.colors.theme": "Theme",
"custom.colors.chat": "Chat name",
"divisions.WEST": "Tentatek",
"divisions.JPN": "Takoroka",

View File

@ -5,6 +5,9 @@
"mapListPreference.ALL_MODES_ONLY": "All modes only",
"mapListPreference.SZ_ONLY": "SZ only",
"mapListPreference.note": "Note: you will not be able to match with teams who chose \"{{optionOne}}\". Consider selecting \"{{optionTwo}}\".",
"roles.OWNER": "Owner",
"roles.MANAGER": "Manager",
"roles.REGULAR": "Regular member",
"vc.YES": "Can voice chat",
"vc.NO": "Can't voice chat",
"vc.LISTEN_ONLY": "Can only listen"