mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 04:54:25 -05:00
Initial
This commit is contained in:
@@ -16,6 +16,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
import { FriendMenu } from "~/features/friends/components/FriendMenu";
|
||||
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
@@ -30,7 +31,6 @@ import { Avatar } from "./Avatar";
|
||||
import { EventsList } from "./EventsList";
|
||||
import { SendouButton } from "./elements/Button";
|
||||
import { Image } from "./Image";
|
||||
import { MOCK_TOTAL_UNREAD } from "./layout/ChatSidebar";
|
||||
import { LogInButtonContainer } from "./layout/LogInButtonContainer";
|
||||
import {
|
||||
NotificationContent,
|
||||
@@ -106,6 +106,7 @@ function MobileTabBar({
|
||||
hasFriendInSendouQ: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["front", "common"]);
|
||||
const chatContext = useChatContext();
|
||||
|
||||
return (
|
||||
<nav className={styles.tabBar}>
|
||||
@@ -135,8 +136,9 @@ function MobileTabBar({
|
||||
icon={<MessageSquare />}
|
||||
label={t("front:mobileNav.chat")}
|
||||
isActive={false}
|
||||
// onPress={() => chatContext?.setSidebarOpen(true)}
|
||||
onPress={() => {}}
|
||||
unreadCount={MOCK_TOTAL_UNREAD}
|
||||
unreadCount={chatContext?.totalUnreadCount}
|
||||
/>
|
||||
<MobileTab
|
||||
icon={<User />}
|
||||
|
||||
@@ -168,6 +168,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: var(--s-4);
|
||||
text-align: center;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.chatContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,133 +1,38 @@
|
||||
import { ArrowLeft, MessageSquare, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "react-aria-components";
|
||||
import type { ChatMessage, ChatUser } from "~/features/chat/chat-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chat } from "~/features/chat/components/Chat";
|
||||
import { navIconUrl } from "~/utils/urls";
|
||||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import styles from "./ChatSidebar.module.css";
|
||||
|
||||
const IMG_ROOT = "https://sendou.nyc3.cdn.digitaloceanspaces.com";
|
||||
const SENDOUQ_ICON = `${navIconUrl("sendouq")}.png`;
|
||||
|
||||
interface MockRoom {
|
||||
name: string;
|
||||
lastMessage: string;
|
||||
timestamp: string;
|
||||
code: string;
|
||||
imageUrl: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
const MOCK_ROOMS: MockRoom[] = [
|
||||
{
|
||||
name: "SQ Match vs Team Alpha",
|
||||
lastMessage: "ggs, well played!",
|
||||
timestamp: "2 min ago",
|
||||
code: "sq-alpha",
|
||||
imageUrl: SENDOUQ_ICON,
|
||||
unreadCount: 3,
|
||||
},
|
||||
{
|
||||
name: "Low Ink March 2026",
|
||||
lastMessage: "Ready to start?",
|
||||
timestamp: "15 min ago",
|
||||
code: "tourney-low-ink",
|
||||
imageUrl: `${IMG_ROOT}/tournament-logo-zWk5C1kvQtEWrd7d2c_KS-1735836299376.webp`,
|
||||
},
|
||||
{
|
||||
name: "SQ Match vs Gamma Squad",
|
||||
lastMessage: "Let's run it back",
|
||||
timestamp: "1 hr ago",
|
||||
code: "sq-gamma",
|
||||
imageUrl: SENDOUQ_ICON,
|
||||
},
|
||||
{
|
||||
name: "Lunar Eclipse 56",
|
||||
lastMessage: "See you tomorrow",
|
||||
timestamp: "3 hrs ago",
|
||||
code: "tourney-lunar",
|
||||
imageUrl: `${IMG_ROOT}/tournament-logo-0oizUoMYLKQkc_EdyDkOT-1767636658472.webp`,
|
||||
unreadCount: 1,
|
||||
},
|
||||
{
|
||||
name: "Shark Tank: Shallow Waters #35",
|
||||
lastMessage: "Who's subbing?",
|
||||
timestamp: "5 hrs ago",
|
||||
code: "tourney-shark",
|
||||
imageUrl: `${IMG_ROOT}/tournament-logo-PyRNcRLpLMXBpO8j8Ho5W-1772403193067.webp`,
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_TOTAL_UNREAD = MOCK_ROOMS.reduce(
|
||||
(sum, room) => sum + (room.unreadCount ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const MOCK_USERS: Record<number, ChatUser> = {
|
||||
1: {
|
||||
username: "Sendou",
|
||||
discordId: "79237403620945920",
|
||||
discordAvatar: null,
|
||||
pronouns: null,
|
||||
chatNameHue: "268",
|
||||
},
|
||||
2: {
|
||||
username: "N-ZAP",
|
||||
discordId: "455039198672453645",
|
||||
discordAvatar: null,
|
||||
pronouns: null,
|
||||
chatNameHue: "120",
|
||||
},
|
||||
};
|
||||
|
||||
function mockMessagesForRoom(roomCode: string): ChatMessage[] {
|
||||
return [
|
||||
{
|
||||
id: `${roomCode}-1`,
|
||||
userId: 2,
|
||||
contents: "Hey, ready for the match?",
|
||||
timestamp: Date.now() - 120000,
|
||||
room: roomCode,
|
||||
},
|
||||
{
|
||||
id: `${roomCode}-2`,
|
||||
userId: 1,
|
||||
contents: "Yeah let's go!",
|
||||
timestamp: Date.now() - 60000,
|
||||
room: roomCode,
|
||||
},
|
||||
{
|
||||
id: `${roomCode}-3`,
|
||||
userId: 2,
|
||||
contents: "glhf",
|
||||
timestamp: Date.now(),
|
||||
room: roomCode,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// xxx: also make it so that if only one room then we go directly to the chat view instead of showing the room list
|
||||
export function ChatSidebar({ onClose }: { onClose?: () => void }) {
|
||||
const [activeRoom, setActiveRoom] = React.useState<MockRoom | null>(null);
|
||||
const chatContext = useChatContext();
|
||||
|
||||
if (activeRoom) {
|
||||
return <ChatView room={activeRoom} onBack={() => setActiveRoom(null)} />;
|
||||
if (!chatContext) return null;
|
||||
|
||||
if (chatContext.activeRoom) {
|
||||
return <ChatView onClose={onClose} />;
|
||||
}
|
||||
|
||||
return <RoomList onSelectRoom={setActiveRoom} onClose={onClose} />;
|
||||
return <RoomList onClose={onClose} />;
|
||||
}
|
||||
|
||||
function RoomList({
|
||||
onSelectRoom,
|
||||
onClose,
|
||||
}: {
|
||||
onSelectRoom: (room: MockRoom) => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
function RoomList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const chatContext = useChatContext()!;
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
const nonExpiredRooms = chatContext.rooms.filter(
|
||||
(room) => room.expiresAt > Date.now(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<MessageSquare size={18} />
|
||||
<h2>Chat</h2>
|
||||
<h2>{t("common:chat.sidebar.title")}</h2>
|
||||
{onClose ? (
|
||||
<Button className={styles.closeButton} onPress={onClose}>
|
||||
<X size={18} />
|
||||
@@ -135,56 +40,94 @@ function RoomList({
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.roomList}>
|
||||
{MOCK_ROOMS.map((room) => (
|
||||
<Button
|
||||
key={room.code}
|
||||
className={styles.roomItem}
|
||||
onPress={() => onSelectRoom(room)}
|
||||
>
|
||||
<img src={room.imageUrl} alt="" className={styles.roomImage} />
|
||||
<div className={styles.roomInfo}>
|
||||
<span className={styles.roomName}>{room.name}</span>
|
||||
<span className={styles.roomLastMessage}>{room.lastMessage}</span>
|
||||
</div>
|
||||
{room.unreadCount ? (
|
||||
<span className={styles.unreadBadge}>{room.unreadCount}</span>
|
||||
) : (
|
||||
<span className={styles.roomTimestamp}>{room.timestamp}</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{nonExpiredRooms.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
{t("common:chat.sidebar.noActiveChats")}
|
||||
</div>
|
||||
) : (
|
||||
nonExpiredRooms.map((room) => {
|
||||
const unread = chatContext.unreadCounts[room.chatCode] ?? 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={room.chatCode}
|
||||
className={styles.roomItem}
|
||||
onPress={() => {
|
||||
chatContext.requestHistory(room.chatCode);
|
||||
chatContext.setActiveRoom(room.chatCode);
|
||||
chatContext.markAsRead(room.chatCode);
|
||||
}}
|
||||
>
|
||||
<div className={styles.roomInfo}>
|
||||
<span className={styles.roomName}>{room.header}</span>
|
||||
<span className={styles.roomLastMessage}>
|
||||
{room.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
{unread > 0 ? (
|
||||
<span className={styles.unreadBadge}>{unread}</span>
|
||||
) : room.lastMessageTimestamp > 0 ? (
|
||||
<span className={styles.roomTimestamp}>
|
||||
{formatDateTime(new Date(room.lastMessageTimestamp), {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatView({ room, onBack }: { room: MockRoom; onBack: () => void }) {
|
||||
const messages = mockMessagesForRoom(room.code);
|
||||
function ChatView({ onClose }: { onClose?: () => void }) {
|
||||
const chatContext = useChatContext()!;
|
||||
const activeRoom = chatContext.activeRoom!;
|
||||
|
||||
const mockChat = {
|
||||
const room = chatContext.rooms.find((r) => r.chatCode === activeRoom);
|
||||
const messages = chatContext.messagesForRoom(activeRoom);
|
||||
|
||||
const chatAdapter = {
|
||||
messages,
|
||||
send: () => {},
|
||||
currentRoom: room.code,
|
||||
send: (contents: string) => chatContext.send(activeRoom, contents),
|
||||
currentRoom: activeRoom,
|
||||
setCurrentRoom: () => {},
|
||||
readyState: "CONNECTED" as const,
|
||||
readyState: chatContext.readyState,
|
||||
unseenMessages: new Map<string, number>(),
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
chatContext.setActiveRoom(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.chatHeader}>
|
||||
<Button className={styles.backButton} onPress={onBack}>
|
||||
<Button className={styles.backButton} onPress={handleBack}>
|
||||
<ArrowLeft size={18} />
|
||||
</Button>
|
||||
<img src={room.imageUrl} alt="" className={styles.chatHeaderImage} />
|
||||
<span className={styles.chatHeaderTitle}>{room.name}</span>
|
||||
<span className={styles.chatHeaderTitle}>
|
||||
{room?.header ?? activeRoom}
|
||||
</span>
|
||||
{onClose ? (
|
||||
<Button className={styles.closeButton} onPress={onClose}>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.chatContainer}>
|
||||
<Chat
|
||||
users={MOCK_USERS}
|
||||
rooms={[{ label: room.name, code: room.code }]}
|
||||
chat={mockChat}
|
||||
disabled
|
||||
users={chatContext.chatUsers}
|
||||
rooms={[
|
||||
{
|
||||
label: room?.header ?? "Chat",
|
||||
code: activeRoom,
|
||||
},
|
||||
]}
|
||||
chat={chatAdapter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Flipped, Flipper } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher, useLocation, useMatches } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
import { FriendMenu } from "~/features/friends/components/FriendMenu";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
|
||||
@@ -40,7 +41,7 @@ import { NotificationDot } from "../NotificationDot";
|
||||
import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
|
||||
import sideNavStyles from "../SideNav.module.css";
|
||||
import { StreamListItems } from "../StreamListItems";
|
||||
import { ChatSidebar, MOCK_TOTAL_UNREAD } from "./ChatSidebar";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import { Footer } from "./Footer";
|
||||
import styles from "./index.module.css";
|
||||
import { LogInButtonContainer } from "./LogInButtonContainer";
|
||||
@@ -172,13 +173,16 @@ export function Layout({
|
||||
children: React.ReactNode;
|
||||
data?: RootLoaderData;
|
||||
}) {
|
||||
const chatContext = useChatContext();
|
||||
const [sideNavCollapsed, setSideNavCollapsed] = useSideNavCollapsed(
|
||||
data?.sidenavCollapsed ?? false,
|
||||
);
|
||||
const [sideNavModalOpen, setSideNavModalOpen] = React.useState(false);
|
||||
const [chatSidebarOpen, setChatSidebarOpen] = React.useState(false);
|
||||
const [chatSidebarModalOpen, setChatSidebarModalOpen] = React.useState(false);
|
||||
|
||||
const chatSidebarOpen = chatContext?.sidebarOpen ?? false;
|
||||
const setChatSidebarOpen = chatContext?.setSidebarOpen ?? (() => {});
|
||||
|
||||
const { t } = useTranslation(["front", "common"]);
|
||||
const { formatRelativeDate } = useTimeFormat();
|
||||
const location = useLocation();
|
||||
@@ -369,7 +373,7 @@ export function Layout({
|
||||
? () => setChatSidebarModalOpen((prev) => !prev)
|
||||
: undefined
|
||||
}
|
||||
chatUnreadCount={MOCK_TOTAL_UNREAD}
|
||||
chatUnreadCount={chatContext?.totalUnreadCount}
|
||||
/>
|
||||
</header>
|
||||
{showLeaderboard ? <MyRampUnit /> : null}
|
||||
|
||||
495
app/features/chat/ChatProvider.tsx
Normal file
495
app/features/chat/ChatProvider.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { WebSocket } from "partysocket";
|
||||
import * as React from "react";
|
||||
import { useMatches, useRevalidator } from "react-router";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { soundPath } from "~/utils/urls";
|
||||
import type { RoomInfo, ServerRoomInfo } from "./chat-provider-types";
|
||||
import type { ChatMessage, ChatUser } from "./chat-types";
|
||||
import { messageTypeToSound, soundEnabled, soundVolume } from "./chat-utils";
|
||||
import { ChatContext } from "./useChatContext";
|
||||
|
||||
const PING_INTERVAL_MS = 60_000;
|
||||
const LOCAL_STORAGE_PREFIX = "chat_read__";
|
||||
const USER_FETCH_BATCH_DELAY_MS = 200;
|
||||
|
||||
function flattenServerRoom(serverRoom: ServerRoomInfo): RoomInfo {
|
||||
return {
|
||||
chatCode: serverRoom.chatCode,
|
||||
header: serverRoom.metadata.header,
|
||||
subtitle: serverRoom.metadata.subtitle ?? "",
|
||||
url: serverRoom.metadata.url ?? "",
|
||||
participantUserIds: serverRoom.metadata.participantUserIds,
|
||||
expiresAt: serverRoom.metadata.expiresAt,
|
||||
lastMessageTimestamp: serverRoom.lastMessageTimestamp ?? 0,
|
||||
totalMessageCount: serverRoom.totalMessageCount,
|
||||
};
|
||||
}
|
||||
|
||||
function localStorageKey(chatCode: string) {
|
||||
return `${LOCAL_STORAGE_PREFIX}${chatCode}`;
|
||||
}
|
||||
|
||||
function readLastReadCount(chatCode: string): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(localStorageKey(chatCode));
|
||||
return stored ? Number(stored) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLastReadCount(chatCode: string, count: number) {
|
||||
try {
|
||||
localStorage.setItem(localStorageKey(chatCode), String(count));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
user,
|
||||
children,
|
||||
}: {
|
||||
user?: { id: number } | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!user) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <ChatProviderInner userId={user.id}>{children}</ChatProviderInner>;
|
||||
}
|
||||
|
||||
function ChatProviderInner({
|
||||
userId,
|
||||
children,
|
||||
}: {
|
||||
userId: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const [rooms, setRooms] = React.useState<RoomInfo[]>([]);
|
||||
const [messagesByRoom, setMessagesByRoom] = React.useState<
|
||||
Record<string, ChatMessage[]>
|
||||
>({});
|
||||
const [chatUsersCache, setChatUsersCache] = React.useState<
|
||||
Record<number, ChatUser>
|
||||
>({});
|
||||
const [readyState, setReadyState] = React.useState<
|
||||
"CONNECTING" | "CONNECTED" | "CLOSED"
|
||||
>("CONNECTING");
|
||||
const [sidebarOpen, _setSidebarOpen] = React.useState(false);
|
||||
const [activeRoom, setActiveRoom] = React.useState<string | null>(null);
|
||||
const [unreadCounts, setUnreadCounts] = React.useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
|
||||
const ws = React.useRef<WebSocket>(undefined);
|
||||
const pendingUserFetches = React.useRef<Set<number>>(new Set());
|
||||
const fetchTimeoutRef =
|
||||
React.useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const computeUnreadCounts = React.useCallback((roomList: RoomInfo[]) => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const room of roomList) {
|
||||
const lastRead = readLastReadCount(room.chatCode);
|
||||
const unread = Math.max(0, room.totalMessageCount - lastRead);
|
||||
counts[room.chatCode] = unread;
|
||||
}
|
||||
setUnreadCounts(counts);
|
||||
}, []);
|
||||
|
||||
const fetchUnknownUsers = React.useCallback((userIds: number[]) => {
|
||||
for (const id of userIds) {
|
||||
pendingUserFetches.current.add(id);
|
||||
}
|
||||
|
||||
clearTimeout(fetchTimeoutRef.current);
|
||||
fetchTimeoutRef.current = setTimeout(async () => {
|
||||
const ids = [...pendingUserFetches.current];
|
||||
pendingUserFetches.current.clear();
|
||||
|
||||
if (ids.length === 0) return;
|
||||
|
||||
try {
|
||||
// xxx: we should use remix fetcher for this
|
||||
const response = await fetch(`/api/chat-users?ids=${ids.join(",")}`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const users = (await response.json()) as Record<number, ChatUser>;
|
||||
setChatUsersCache((prev) => ({ ...prev, ...users }));
|
||||
} catch {
|
||||
// fetch failed, will retry on next unknown user
|
||||
}
|
||||
}, USER_FETCH_BATCH_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
const onMessage = React.useEffectEvent((e: MessageEvent) => {
|
||||
const parsed = JSON.parse(e.data);
|
||||
|
||||
// Initial rooms payload on connect
|
||||
if (parsed.rooms && Array.isArray(parsed.rooms)) {
|
||||
const serverRooms = parsed.rooms as ServerRoomInfo[];
|
||||
const roomList = serverRooms.map(flattenServerRoom);
|
||||
setRooms(roomList);
|
||||
|
||||
const allChatUsers: Record<number, ChatUser> = {};
|
||||
for (const sr of serverRooms) {
|
||||
Object.assign(allChatUsers, sr.metadata.chatUsers);
|
||||
}
|
||||
setChatUsersCache((prev) => ({ ...prev, ...allChatUsers }));
|
||||
computeUnreadCounts(roomList);
|
||||
return;
|
||||
}
|
||||
|
||||
// ROOM_JOINED: new room added
|
||||
if (parsed.event === "ROOM_JOINED" && parsed.room) {
|
||||
const serverRoom = parsed.room as ServerRoomInfo;
|
||||
const newRoom = flattenServerRoom(serverRoom);
|
||||
setRooms((prev) => {
|
||||
const exists = prev.some((r) => r.chatCode === newRoom.chatCode);
|
||||
if (exists) return prev;
|
||||
return [...prev, newRoom];
|
||||
});
|
||||
|
||||
setChatUsersCache((prev) => ({
|
||||
...prev,
|
||||
...serverRoom.metadata.chatUsers,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// CHAT_HISTORY response
|
||||
if (parsed.event === "CHAT_HISTORY" && Array.isArray(parsed.messages)) {
|
||||
const chatCode = parsed.chatCode as string;
|
||||
const messages = parsed.messages as ChatMessage[];
|
||||
setMessagesByRoom((prev) => ({
|
||||
...prev,
|
||||
[chatCode]: messages,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular message(s)
|
||||
const messageArr = (
|
||||
Array.isArray(parsed) ? parsed : [parsed]
|
||||
) as (ChatMessage & { totalMessageCount?: number })[];
|
||||
|
||||
const isSystemMessage = Boolean(messageArr[0].type);
|
||||
if (isSystemMessage) {
|
||||
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((err) => logger.error(`Couldn't play sound: ${err}`));
|
||||
}
|
||||
|
||||
if (messageArr[0].revalidateOnly) return;
|
||||
|
||||
for (const msg of messageArr) {
|
||||
const roomCode = msg.room;
|
||||
setMessagesByRoom((prev) => {
|
||||
const existing = prev[roomCode] ?? [];
|
||||
const pendingIndex = existing.findIndex(
|
||||
(m) => m.id === msg.id && m.pending,
|
||||
);
|
||||
|
||||
if (pendingIndex !== -1) {
|
||||
const updated = [...existing];
|
||||
updated[pendingIndex] = msg;
|
||||
return { ...prev, [roomCode]: updated };
|
||||
}
|
||||
|
||||
return { ...prev, [roomCode]: [...existing, msg] };
|
||||
});
|
||||
|
||||
if (msg.userId && !chatUsersCache[msg.userId]) {
|
||||
fetchUnknownUsers([msg.userId]);
|
||||
}
|
||||
|
||||
if (msg.totalMessageCount) {
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.chatCode === roomCode
|
||||
? {
|
||||
...r,
|
||||
lastMessageTimestamp: msg.timestamp,
|
||||
totalMessageCount: Math.max(
|
||||
r.totalMessageCount,
|
||||
msg.totalMessageCount!,
|
||||
),
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
|
||||
if (roomCode === activeRoom && sidebarOpen) {
|
||||
writeLastReadCount(roomCode, msg.totalMessageCount);
|
||||
} else {
|
||||
setUnreadCounts((prev) => ({
|
||||
...prev,
|
||||
[roomCode]: (prev[roomCode] ?? 0) + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket connection
|
||||
React.useEffect(() => {
|
||||
const wsUrl = import.meta.env.VITE_SKALOP_WS_URL;
|
||||
if (!wsUrl) {
|
||||
logger.warn("No WS URL provided, ChatProvider not connecting");
|
||||
setReadyState("CLOSED");
|
||||
return;
|
||||
}
|
||||
|
||||
ws.current = new WebSocket(wsUrl, [], {
|
||||
maxReconnectionDelay: 20_000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
});
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setReadyState("CONNECTED");
|
||||
};
|
||||
|
||||
ws.current.onclose = () => setReadyState("CLOSED");
|
||||
ws.current.onerror = () => setReadyState("CLOSED");
|
||||
ws.current.onmessage = onMessage;
|
||||
|
||||
const wsCurrent = ws.current;
|
||||
return () => {
|
||||
wsCurrent?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Ping to keep connection alive
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
ws.current?.send("");
|
||||
}, PING_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Listen for cross-tab localStorage changes for unread tracking
|
||||
React.useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (!e.key?.startsWith(LOCAL_STORAGE_PREFIX)) return;
|
||||
const chatCode = e.key.slice(LOCAL_STORAGE_PREFIX.length);
|
||||
const newCount = e.newValue ? Number(e.newValue) : 0;
|
||||
|
||||
setUnreadCounts((prev) => {
|
||||
const room = rooms.find((r) => r.chatCode === chatCode);
|
||||
if (!room) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[chatCode]: Math.max(0, room.totalMessageCount - newCount),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, [rooms]);
|
||||
|
||||
const messagesForRoom = React.useCallback(
|
||||
(chatCode: string) => {
|
||||
return (messagesByRoom[chatCode] ?? []).sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
},
|
||||
[messagesByRoom],
|
||||
);
|
||||
|
||||
const send = React.useCallback(
|
||||
(chatCode: string, contents: string) => {
|
||||
const id = nanoid();
|
||||
const message: ChatMessage = {
|
||||
id,
|
||||
room: chatCode,
|
||||
contents,
|
||||
timestamp: Date.now(),
|
||||
userId,
|
||||
pending: true,
|
||||
};
|
||||
|
||||
// Optimistic add
|
||||
setMessagesByRoom((prev) => ({
|
||||
...prev,
|
||||
[chatCode]: [...(prev[chatCode] ?? []), message],
|
||||
}));
|
||||
|
||||
ws.current?.send(
|
||||
JSON.stringify({ event: "MESSAGE", chatCode, id, contents }),
|
||||
);
|
||||
},
|
||||
[userId],
|
||||
);
|
||||
|
||||
const subscribe = React.useCallback((chatCode: string) => {
|
||||
ws.current?.send(JSON.stringify({ event: "SUBSCRIBE", chatCode }));
|
||||
}, []);
|
||||
|
||||
const unsubscribe = React.useCallback((chatCode: string) => {
|
||||
ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE", chatCode }));
|
||||
}, []);
|
||||
|
||||
const requestHistory = React.useCallback((chatCode: string) => {
|
||||
ws.current?.send(JSON.stringify({ event: "CHAT_HISTORY", chatCode }));
|
||||
}, []);
|
||||
|
||||
const markAsRead = React.useCallback(
|
||||
(chatCode: string) => {
|
||||
const room = rooms.find((r) => r.chatCode === chatCode);
|
||||
const messageCount =
|
||||
room?.totalMessageCount ?? messagesByRoom[chatCode]?.length ?? 0;
|
||||
writeLastReadCount(chatCode, messageCount);
|
||||
setUnreadCounts((prev) => ({ ...prev, [chatCode]: 0 }));
|
||||
},
|
||||
[rooms, messagesByRoom],
|
||||
);
|
||||
|
||||
const totalUnreadCount = Object.values(unreadCounts).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
|
||||
const setSidebarOpen = React.useCallback(
|
||||
(open: boolean) => {
|
||||
_setSidebarOpen(open);
|
||||
if (open && activeRoom) {
|
||||
markAsRead(activeRoom);
|
||||
}
|
||||
},
|
||||
[activeRoom, markAsRead],
|
||||
);
|
||||
|
||||
useChatRouteSync({
|
||||
rooms,
|
||||
userId,
|
||||
setActiveRoom,
|
||||
setSidebarOpen,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
});
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
rooms,
|
||||
messagesForRoom,
|
||||
send,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestHistory,
|
||||
markAsRead,
|
||||
unreadCounts,
|
||||
totalUnreadCount,
|
||||
readyState,
|
||||
chatUsers: chatUsersCache,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
activeRoom,
|
||||
setActiveRoom,
|
||||
}),
|
||||
[
|
||||
rooms,
|
||||
messagesForRoom,
|
||||
send,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestHistory,
|
||||
markAsRead,
|
||||
unreadCounts,
|
||||
totalUnreadCount,
|
||||
readyState,
|
||||
chatUsersCache,
|
||||
sidebarOpen,
|
||||
activeRoom,
|
||||
setSidebarOpen,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={contextValue}>{children}</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useChatRouteSync({
|
||||
rooms,
|
||||
userId,
|
||||
setActiveRoom,
|
||||
setSidebarOpen,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
}: {
|
||||
rooms: RoomInfo[];
|
||||
userId: number;
|
||||
setActiveRoom: (chatCode: string | null) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
subscribe: (chatCode: string) => void;
|
||||
unsubscribe: (chatCode: string) => void;
|
||||
}) {
|
||||
const matches = useMatches();
|
||||
const subscribedRoomRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let chatCode: string | null = null;
|
||||
|
||||
for (const match of matches) {
|
||||
const matchData = match.data as { chatCode?: string } | undefined;
|
||||
if (matchData?.chatCode) {
|
||||
chatCode = matchData.chatCode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chatCode) {
|
||||
// Leaving a chat route: unsubscribe from staff-subscribed rooms
|
||||
if (subscribedRoomRef.current) {
|
||||
unsubscribe(subscribedRoomRef.current);
|
||||
subscribedRoomRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const room = rooms.find((r) => r.chatCode === chatCode);
|
||||
|
||||
if (room) {
|
||||
const isParticipant = room.participantUserIds.includes(userId);
|
||||
|
||||
if (isParticipant) {
|
||||
setActiveRoom(chatCode);
|
||||
setSidebarOpen(true);
|
||||
} else {
|
||||
// Staff/TO viewing: subscribe temporarily
|
||||
subscribe(chatCode);
|
||||
subscribedRoomRef.current = chatCode;
|
||||
setActiveRoom(chatCode);
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (subscribedRoomRef.current) {
|
||||
unsubscribe(subscribedRoomRef.current);
|
||||
subscribedRoomRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
matches,
|
||||
rooms,
|
||||
userId,
|
||||
setActiveRoom,
|
||||
setSidebarOpen,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { nanoid } from "nanoid";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { ChatMessage } from "./chat-types";
|
||||
import type { ChatMessage, ChatUser } from "./chat-types";
|
||||
|
||||
const SKALOP_TOKEN_HEADER_NAME = "Skalop-Token";
|
||||
|
||||
@@ -47,7 +47,65 @@ export const send: ChatSystemMessageService["send"] = (partialMsg) => {
|
||||
|
||||
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL!, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(fullMessages),
|
||||
headers: [[SKALOP_TOKEN_HEADER_NAME, process.env.SKALOP_TOKEN!]],
|
||||
body: JSON.stringify({
|
||||
action: "sendMessage",
|
||||
messages: fullMessages,
|
||||
}),
|
||||
headers: [
|
||||
[SKALOP_TOKEN_HEADER_NAME, process.env.SKALOP_TOKEN!],
|
||||
["Content-Type", "application/json"],
|
||||
],
|
||||
}).catch(logger.error);
|
||||
};
|
||||
|
||||
interface SetMetadataArgs {
|
||||
chatCode: string;
|
||||
header: string;
|
||||
subtitle: string;
|
||||
url: string;
|
||||
participantUserIds: number[];
|
||||
chatUsers: Record<number, ChatUser>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// xxx: actually there is no dedup like this, for as long as the service is up, we dont resend metadata
|
||||
const DEDUP_INTERVAL_MS = 30_000;
|
||||
const DEDUP_PRUNE_MS = 1000 * 60 * 60 * 24 * 30;
|
||||
const metadataDedup = new Map<string, number>();
|
||||
|
||||
export function setMetadata(args: SetMetadataArgs) {
|
||||
if (systemMessagesDisabled) return;
|
||||
if (!process.env.SKALOP_SYSTEM_MESSAGE_URL) return;
|
||||
|
||||
const now = Date.now();
|
||||
const lastSent = metadataDedup.get(args.chatCode);
|
||||
if (lastSent && now - lastSent < DEDUP_INTERVAL_MS) return;
|
||||
metadataDedup.set(args.chatCode, now);
|
||||
|
||||
// Prune old entries
|
||||
for (const [key, timestamp] of metadataDedup) {
|
||||
if (now - timestamp > DEDUP_PRUNE_MS) {
|
||||
metadataDedup.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "setMetadata",
|
||||
chatCode: args.chatCode,
|
||||
metadata: {
|
||||
participantUserIds: args.participantUserIds,
|
||||
chatUsers: args.chatUsers,
|
||||
expiresAt: args.expiresAt,
|
||||
header: args.header,
|
||||
subtitle: args.subtitle,
|
||||
url: args.url,
|
||||
},
|
||||
}),
|
||||
headers: [
|
||||
[SKALOP_TOKEN_HEADER_NAME, process.env.SKALOP_TOKEN!],
|
||||
["Content-Type", "application/json"],
|
||||
],
|
||||
}).catch(logger.error);
|
||||
}
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export const MESSAGE_MAX_LENGTH = 200;
|
||||
|
||||
// xxx: use date-fns
|
||||
export const SENDOUQ_MATCH_EXPIRY_MS = 1000 * 60 * 60;
|
||||
export const TOURNAMENT_MATCH_EXPIRY_MS = 1000 * 60 * 60 * 2;
|
||||
export const LEAGUE_MATCH_EXPIRY_MS = 1000 * 60 * 60 * 24 * 30;
|
||||
export const SCRIM_EXPIRY_MS = 1000 * 60 * 60 * 3;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { WebSocket } from "partysocket";
|
||||
import React from "react";
|
||||
import { useRevalidator } from "react-router";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { soundPath } from "~/utils/urls";
|
||||
import * as React from "react";
|
||||
import { useUser } from "../auth/core/user";
|
||||
import type { ChatMessage, ChatProps } from "./chat-types";
|
||||
import { messageTypeToSound, soundEnabled, soundVolume } from "./chat-utils";
|
||||
import type { ChatMessage } from "./chat-types";
|
||||
|
||||
// increasing this = scrolling happens even when scrolled more upwards
|
||||
const THRESHOLD = 100;
|
||||
@@ -76,211 +69,3 @@ 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<boolean>(undefined);
|
||||
const user = useUser();
|
||||
|
||||
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
|
||||
const [readyState, setReadyState] = React.useState<
|
||||
"CONNECTING" | "CONNECTED" | "CLOSED"
|
||||
>("CONNECTING");
|
||||
const [sentMessage, setSentMessage] = React.useState<ChatMessage>();
|
||||
const [currentRoom, setCurrentRoom] = React.useState<string | undefined>(
|
||||
rooms[0]?.code,
|
||||
);
|
||||
|
||||
const ws = React.useRef<WebSocket>(undefined);
|
||||
const lastSeenMessagesByRoomId = React.useRef<Map<string, string>>(new Map());
|
||||
|
||||
// same principal as here behind separating it into a ref: https://overreacted.io/making-setinterval-declarative-with-react-hooks/
|
||||
React.useEffect(() => {
|
||||
shouldRevalidate.current = revalidates;
|
||||
}, [revalidates]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rooms.length === 0 || !connected) return;
|
||||
if (!import.meta.env.VITE_SKALOP_WS_URL) {
|
||||
logger.warn("No WS URL provided");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${import.meta.env.VITE_SKALOP_WS_URL}?${rooms
|
||||
.map((room) => `room=${room.code}`)
|
||||
.join("&")}`;
|
||||
ws.current = new WebSocket(url, [], {
|
||||
maxReconnectionDelay: 10000 * 2,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
});
|
||||
ws.current.onopen = () => {
|
||||
setCurrentRoom(rooms[0].code);
|
||||
setReadyState("CONNECTED");
|
||||
};
|
||||
ws.current.onclose = () => setReadyState("CLOSED");
|
||||
ws.current.onerror = () => setReadyState("CLOSED");
|
||||
|
||||
ws.current.onmessage = (e) => {
|
||||
const message = JSON.parse(e.data);
|
||||
const messageArr = (
|
||||
Array.isArray(message) ? message : [message]
|
||||
) as ChatMessage[];
|
||||
|
||||
// something interesting happened
|
||||
// -> let's run data loaders so they can see it without needing to refresh the page
|
||||
const isSystemMessage = Boolean(messageArr[0].type);
|
||||
if (isSystemMessage && shouldRevalidate.current) {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
const sound = messageTypeToSound(messageArr[0].type);
|
||||
if (sound && soundEnabled(sound)) {
|
||||
const audio = new Audio(soundPath(sound));
|
||||
audio.volume = soundVolume() / 100;
|
||||
void audio
|
||||
.play()
|
||||
.catch((e) => logger.error(`Couldn't play sound: ${e}`));
|
||||
}
|
||||
|
||||
if (messageArr[0].revalidateOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInitialLoad = Array.isArray(message);
|
||||
|
||||
if (isInitialLoad) {
|
||||
lastSeenMessagesByRoomId.current = message.reduce((acc, cur) => {
|
||||
acc.set(cur.room, cur.id);
|
||||
return acc;
|
||||
}, new Map<string, string>());
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
setMessages(messageArr);
|
||||
} else {
|
||||
if (!isSystemMessage) onNewMessage?.(message);
|
||||
setMessages((messages) => [...messages, ...messageArr]);
|
||||
}
|
||||
};
|
||||
|
||||
const wsCurrent = ws.current;
|
||||
return () => {
|
||||
wsCurrent?.close();
|
||||
setMessages([]);
|
||||
};
|
||||
}, [rooms, onNewMessage, revalidate, connected]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// ping every minute to keep connection alive
|
||||
const interval = setInterval(() => {
|
||||
ws.current?.send("");
|
||||
}, 1000 * 60);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const send = React.useCallback(
|
||||
(contents: string) => {
|
||||
invariant(currentRoom);
|
||||
|
||||
const id = nanoid();
|
||||
setSentMessage({
|
||||
id,
|
||||
room: currentRoom,
|
||||
contents,
|
||||
timestamp: Date.now(),
|
||||
userId: user!.id,
|
||||
});
|
||||
ws.current!.send(JSON.stringify({ id, contents, room: currentRoom }));
|
||||
},
|
||||
[user, currentRoom],
|
||||
);
|
||||
|
||||
let allMessages = messages;
|
||||
if (sentMessage && !messages.some((msg) => msg.id === sentMessage.id)) {
|
||||
allMessages = [...messages, { ...sentMessage, pending: true }];
|
||||
}
|
||||
|
||||
const roomsMessages = allMessages
|
||||
.filter((msg) => msg.room === currentRoom)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
if (roomsMessages.length > 0 && currentRoom) {
|
||||
lastSeenMessagesByRoomId.current.set(
|
||||
currentRoom,
|
||||
roomsMessages[roomsMessages.length - 1].id,
|
||||
);
|
||||
}
|
||||
|
||||
const unseenMessages = unseenMessagesCountByRoomId({
|
||||
messages,
|
||||
lastSeenMessages: lastSeenMessagesByRoomId.current,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: roomsMessages,
|
||||
send,
|
||||
currentRoom,
|
||||
setCurrentRoom,
|
||||
readyState,
|
||||
unseenMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/** Listens to system messages sent via WebSocket to the given room triggering data loader revalidations. */
|
||||
export function useWebsocketRevalidation({
|
||||
room,
|
||||
connected,
|
||||
}: {
|
||||
room: string;
|
||||
/** If true, the websocket is connected. Defaults to true. */
|
||||
connected?: boolean;
|
||||
}) {
|
||||
const rooms = React.useMemo(() => [{ label: room, code: room }], [room]);
|
||||
|
||||
useChat({
|
||||
rooms,
|
||||
connected,
|
||||
});
|
||||
}
|
||||
|
||||
function unseenMessagesCountByRoomId({
|
||||
messages,
|
||||
lastSeenMessages,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
lastSeenMessages: Map<string, string>;
|
||||
}) {
|
||||
const lastUnseenEncountered = new Set<string>();
|
||||
|
||||
const unseenMessages = messages.filter((msg) => {
|
||||
if (msg.id === lastSeenMessages.get(msg.room)) {
|
||||
lastUnseenEncountered.add(msg.room);
|
||||
return false;
|
||||
}
|
||||
|
||||
return lastUnseenEncountered.has(msg.room);
|
||||
});
|
||||
|
||||
return unseenMessages.reduce((acc, cur) => {
|
||||
const count = acc.get(cur.room) ?? 0;
|
||||
acc.set(cur.room, count + 1);
|
||||
return acc;
|
||||
}, new Map<string, number>());
|
||||
}
|
||||
|
||||
49
app/features/chat/chat-provider-types.ts
Normal file
49
app/features/chat/chat-provider-types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ChatMessage, ChatUser } from "./chat-types";
|
||||
|
||||
/** Metadata stored per room on the Skalop server */
|
||||
export interface RoomMetadata {
|
||||
participantUserIds: number[];
|
||||
chatUsers: Record<number, ChatUser>;
|
||||
expiresAt: number;
|
||||
header: string;
|
||||
subtitle?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** Room info as returned by Skalop on connect and ROOM_JOINED events */
|
||||
export interface ServerRoomInfo {
|
||||
chatCode: string;
|
||||
metadata: RoomMetadata;
|
||||
lastMessageTimestamp: number | null;
|
||||
totalMessageCount: number;
|
||||
}
|
||||
|
||||
/** Flattened room info used by the UI */
|
||||
export interface RoomInfo {
|
||||
chatCode: string;
|
||||
header: string;
|
||||
subtitle: string;
|
||||
url: string;
|
||||
participantUserIds: number[];
|
||||
expiresAt: number;
|
||||
lastMessageTimestamp: number;
|
||||
totalMessageCount: number;
|
||||
}
|
||||
|
||||
export interface ChatContextValue {
|
||||
rooms: RoomInfo[];
|
||||
messagesForRoom: (chatCode: string) => ChatMessage[];
|
||||
send: (chatCode: string, contents: string) => void;
|
||||
subscribe: (chatCode: string) => void;
|
||||
unsubscribe: (chatCode: string) => void;
|
||||
requestHistory: (chatCode: string) => void;
|
||||
markAsRead: (chatCode: string) => void;
|
||||
unreadCounts: Record<string, number>;
|
||||
totalUnreadCount: number;
|
||||
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
|
||||
chatUsers: Record<number, ChatUser>;
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
activeRoom: string | null;
|
||||
setActiveRoom: (chatCode: string | null) => void;
|
||||
}
|
||||
@@ -42,10 +42,8 @@ export interface ChatProps {
|
||||
className?: string;
|
||||
messagesContainerClassName?: string;
|
||||
hidden?: boolean;
|
||||
onNewMessage?: (message: ChatMessage) => void;
|
||||
onMount?: () => void;
|
||||
onUnmount?: () => void;
|
||||
disabled?: boolean;
|
||||
missingUserName?: string;
|
||||
revalidates?: boolean;
|
||||
}
|
||||
|
||||
@@ -9,18 +9,19 @@ import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SubmitButton } from "../../../components/SubmitButton";
|
||||
import { useTimeFormat } from "../../../hooks/useTimeFormat";
|
||||
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
|
||||
import { useChat, useChatAutoScroll } from "../chat-hooks";
|
||||
import { useChatAutoScroll } from "../chat-hooks";
|
||||
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
|
||||
import styles from "./Chat.module.css";
|
||||
|
||||
export function ConnectedChat(props: ChatProps) {
|
||||
const chat = useChat(props);
|
||||
|
||||
return <Chat {...props} chat={chat} />;
|
||||
export interface ChatAdapter {
|
||||
messages: ChatMessage[];
|
||||
send: (contents: string) => void;
|
||||
currentRoom: string | undefined;
|
||||
setCurrentRoom: (room: string) => void;
|
||||
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
|
||||
unseenMessages: Map<string, number>;
|
||||
}
|
||||
|
||||
// xxx: if we go with global chat refactor a lot / delete this
|
||||
|
||||
export function Chat({
|
||||
users,
|
||||
rooms,
|
||||
@@ -32,8 +33,8 @@ export function Chat({
|
||||
onUnmount,
|
||||
disabled,
|
||||
missingUserName,
|
||||
}: Omit<ChatProps, "revalidates" | "onNewMessage"> & {
|
||||
chat: ReturnType<typeof useChat>;
|
||||
}: Omit<ChatProps, "onNewMessage" | "revalidates"> & {
|
||||
chat: ChatAdapter;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const messagesContainerRef = React.useRef<HTMLOListElement>(null);
|
||||
|
||||
27
app/features/chat/routes/api.chat-users.ts
Normal file
27
app/features/chat/routes/api.chat-users.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
requireUser();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const idsParam = url.searchParams.get("ids");
|
||||
|
||||
if (!idsParam) {
|
||||
return Response.json({});
|
||||
}
|
||||
|
||||
// xxx: use zod
|
||||
const parsedIds = idsParam
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter((id) => Number.isInteger(id) && id > 0);
|
||||
|
||||
if (parsedIds.length === 0) {
|
||||
return Response.json({});
|
||||
}
|
||||
|
||||
const users = await UserRepository.findChatUsersByUserIds(parsedIds);
|
||||
return Response.json(users);
|
||||
};
|
||||
8
app/features/chat/useChatContext.ts
Normal file
8
app/features/chat/useChatContext.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as React from "react";
|
||||
import type { ChatContextValue } from "./chat-provider-types";
|
||||
|
||||
export const ChatContext = React.createContext<ChatContextValue | null>(null);
|
||||
|
||||
export function useChatContext(): ChatContextValue | null {
|
||||
return React.useContext(ChatContext);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { setMetadata } from "~/features/chat/ChatSystemMessage.server";
|
||||
import { SCRIM_EXPIRY_MS } from "~/features/chat/chat-constants";
|
||||
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { notFoundIfFalsy } from "../../../utils/remix.server";
|
||||
@@ -25,10 +27,24 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
}
|
||||
|
||||
const participantIds = Scrim.participantIdsListFromAccepted(post);
|
||||
const chatUsers = await UserRepository.findChatUsersByUserIds(participantIds);
|
||||
|
||||
// xxx: additional condition
|
||||
if (post.chatCode) {
|
||||
setMetadata({
|
||||
chatCode: post.chatCode,
|
||||
// xxx: better header+subtitle
|
||||
header: "Scrim",
|
||||
subtitle: `Scrim #${post.id}`,
|
||||
url: `/scrims/${post.id}`,
|
||||
participantUserIds: participantIds,
|
||||
chatUsers,
|
||||
expiresAt: Date.now() + SCRIM_EXPIRY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
post,
|
||||
chatUsers: await UserRepository.findChatUsersByUserIds(participantIds),
|
||||
anyUserPrefersNoScreen:
|
||||
await UserRepository.anyUserPrefersNoScreen(participantIds),
|
||||
tournamentMapPool: post.mapsTournament
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { Alert } from "~/components/Alert";
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
teamPage,
|
||||
userPage,
|
||||
} from "../../../utils/urls";
|
||||
import { ConnectedChat } from "../../chat/components/Chat";
|
||||
import { action } from "../actions/scrims.$id.server";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import { loader } from "../loaders/scrims.$id.server";
|
||||
@@ -110,7 +108,6 @@ export default function ScrimPage() {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ScrimChat />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -276,24 +273,3 @@ function MapsLink({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrimChat() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const chatCode = data.post.chatCode;
|
||||
const rooms = React.useMemo(
|
||||
() => (chatCode ? [{ label: "Scrim", code: chatCode }] : []),
|
||||
[chatCode],
|
||||
);
|
||||
|
||||
if (!chatCode) {
|
||||
logger.warn("No chat code found");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.chatContainer}>
|
||||
<ConnectedChat users={data.chatUsers} rooms={rooms} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { setMetadata } from "~/features/chat/ChatSystemMessage.server";
|
||||
import { SENDOUQ_MATCH_EXPIRY_MS } from "~/features/chat/chat-constants";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import { reportedWeaponsToArrayOfArrays } from "~/features/sendouq-match/core/reported-weapons.server";
|
||||
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { qMatchPageParamsSchema } from "../q-match-schemas";
|
||||
|
||||
@@ -32,6 +35,27 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
? await ReportedWeaponRepository.findByMatchId(matchId)
|
||||
: null;
|
||||
|
||||
// xxx: also if match is even ongoing
|
||||
if (match.chatCode && user) {
|
||||
const participantIds = [
|
||||
...matchUnmapped.groupAlpha.members,
|
||||
...matchUnmapped.groupBravo.members,
|
||||
].map((m) => m.id);
|
||||
|
||||
const chatUsers =
|
||||
await UserRepository.findChatUsersByUserIds(participantIds);
|
||||
|
||||
setMetadata({
|
||||
chatCode: match.chatCode,
|
||||
header: "SQ Match",
|
||||
subtitle: `Match #${matchId}`,
|
||||
url: `/q/match/${matchId}`,
|
||||
participantUserIds: participantIds,
|
||||
chatUsers,
|
||||
expiresAt: Date.now() + SENDOUQ_MATCH_EXPIRY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
match,
|
||||
reportedWeapons: match.reportedAt
|
||||
|
||||
@@ -17,12 +17,6 @@ import { Divider } from "~/components/Divider";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image";
|
||||
import { DiscordIcon } from "~/components/icons/Discord";
|
||||
@@ -32,9 +26,6 @@ import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
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";
|
||||
@@ -662,70 +653,12 @@ function BottomSection({
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < 750;
|
||||
const isMounted = useIsMounted();
|
||||
const [isReportingWeapons, setIsReportingWeapons] = React.useState(false);
|
||||
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const submitScoreFetcher = useFetcher<typeof action>();
|
||||
const cancelFetcher = useFetcher<typeof action>();
|
||||
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
[...data.match.groupAlpha.members, ...data.match.groupBravo.members].map(
|
||||
(m) => [m.id, m],
|
||||
),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const [_unseenMessages, setUnseenMessages] = React.useState(0);
|
||||
const [chatVisible, setChatVisible] = React.useState(false);
|
||||
|
||||
const onNewMessage = React.useCallback(() => {
|
||||
setUnseenMessages((msg) => msg + 1);
|
||||
}, []);
|
||||
|
||||
const groupChatCode =
|
||||
data.match.groupAlpha.chatCode ?? data.match.groupBravo.chatCode;
|
||||
|
||||
const chatRooms = React.useMemo(() => {
|
||||
return [
|
||||
data.match.chatCode
|
||||
? { code: data.match.chatCode, label: "Match" }
|
||||
: null,
|
||||
groupChatCode ? { code: groupChatCode, label: "Group" } : null,
|
||||
].filter(Boolean) as ChatProps["rooms"];
|
||||
}, [data.match.chatCode, groupChatCode]);
|
||||
|
||||
const chatHidden = chatRooms.length === 0;
|
||||
|
||||
const [selectedTabKey, setSelectedTabKey] = React.useState<string>(
|
||||
chatHidden ? "report" : "chat",
|
||||
);
|
||||
|
||||
const ownWeaponsReported = data.rawReportedWeapons?.some(
|
||||
(rw) => rw.userId === user?.id,
|
||||
);
|
||||
|
||||
// revalidates: false when we don't want the user to lose the weapons
|
||||
// they are reporting when the match gets suddenly locked
|
||||
const chat = useChat({
|
||||
rooms: chatRooms,
|
||||
onNewMessage,
|
||||
revalidates: ownWeaponsReported || !isReportingWeapons,
|
||||
});
|
||||
|
||||
const onChatMount = React.useCallback(() => {
|
||||
setChatVisible(true);
|
||||
}, []);
|
||||
|
||||
const onChatUnmount = React.useCallback(() => {
|
||||
setChatVisible(false);
|
||||
setUnseenMessages(0);
|
||||
}, []);
|
||||
|
||||
const unseenMessages = chatVisible ? 0 : _unseenMessages;
|
||||
|
||||
const showMid = !data.match.isLocked && (participatingInTheMatch || isStaff);
|
||||
|
||||
const poolCode = () => {
|
||||
@@ -737,24 +670,12 @@ function BottomSection({
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
const chatElement = (
|
||||
<Chat
|
||||
chat={chat}
|
||||
onMount={onChatMount}
|
||||
onUnmount={onChatUnmount}
|
||||
users={chatUsers}
|
||||
rooms={chatRooms}
|
||||
disabled={!groupChatCode} // no message sending by staff to match chat
|
||||
/>
|
||||
);
|
||||
|
||||
const mapListElement = (
|
||||
<MapList
|
||||
key={data.match.id}
|
||||
canReportScore={canReportScore}
|
||||
isResubmission={ownTeamReported}
|
||||
fetcher={submitScoreFetcher}
|
||||
setIsReportingWeapons={setIsReportingWeapons}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -829,7 +750,7 @@ function BottomSection({
|
||||
<ScreenLegalityInfo ban={screenBanned} />
|
||||
) : null;
|
||||
|
||||
if (!showMid && chatHidden) {
|
||||
if (!showMid) {
|
||||
return mapListElement;
|
||||
}
|
||||
|
||||
@@ -845,32 +766,7 @@ function BottomSection({
|
||||
{cancelMatchElement}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SendouTabs
|
||||
selectedKey={selectedTabKey}
|
||||
onSelectionChange={(key) => setSelectedTabKey(key as string)}
|
||||
>
|
||||
<SendouTabList sticky>
|
||||
{!chatHidden && (
|
||||
<SendouTab id="chat" number={unseenMessages}>
|
||||
{t("q:looking.columns.chat")}
|
||||
</SendouTab>
|
||||
)}
|
||||
<SendouTab id="report">{t("q:match.tabs.reportScore")}</SendouTab>
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="chat">{chatElement}</SendouTabPanel>
|
||||
<SendouTabPanel
|
||||
id="report"
|
||||
shouldForceMount
|
||||
className={clsx({
|
||||
hidden: selectedTabKey !== "report",
|
||||
})}
|
||||
>
|
||||
{mapListElement}
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</div>
|
||||
{mapListElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -892,9 +788,6 @@ function BottomSection({
|
||||
{cancelMatchElement}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.chatContainer}>
|
||||
{chatRooms.length > 0 ? chatElement : null}
|
||||
</div>
|
||||
</div>
|
||||
{cancelFetcher.data?.error === "cant-cancel" ? (
|
||||
<div className="text-xs text-warning font-semi-bold text-center">
|
||||
@@ -960,12 +853,10 @@ function MapList({
|
||||
canReportScore,
|
||||
isResubmission,
|
||||
fetcher,
|
||||
setIsReportingWeapons,
|
||||
}: {
|
||||
canReportScore: boolean;
|
||||
isResubmission: boolean;
|
||||
fetcher: FetcherWithComponents<any>;
|
||||
setIsReportingWeapons: (val: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
@@ -1026,9 +917,7 @@ function MapList({
|
||||
addRecentlyReportedWeapon={addRecentlyReportedWeapon}
|
||||
onOwnWeaponSelected={(newReportedWeapon) => {
|
||||
if (!newReportedWeapon) return;
|
||||
|
||||
setIsReportingWeapons(true);
|
||||
|
||||
// xxx: previously revalidation was suppressed during weapon reporting to avoid race conditions, needs new mechanism with global ChatProvider
|
||||
setOwnWeaponsUsage((val) => {
|
||||
const result = val.filter(
|
||||
(reportedWeapon) =>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { setMetadata } from "~/features/chat/ChatSystemMessage.server";
|
||||
import { SENDOUQ_MATCH_EXPIRY_MS } from "~/features/chat/chat-constants";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { groupExpiryStatus } from "../core/groups";
|
||||
import { SendouQ } from "../core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
|
||||
@@ -32,6 +35,22 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (ownGroup?.chatCode) {
|
||||
const memberIds = ownGroup.members.map((m: { id: number }) => m.id);
|
||||
const chatUsers = await UserRepository.findChatUsersByUserIds(memberIds);
|
||||
|
||||
setMetadata({
|
||||
chatCode: ownGroup.chatCode,
|
||||
header: "SQ Group",
|
||||
// xxx: better subtitle
|
||||
subtitle: "Looking for match",
|
||||
url: "/q/looking",
|
||||
participantUserIds: memberIds,
|
||||
chatUsers,
|
||||
expiresAt: Date.now() + SENDOUQ_MATCH_EXPIRY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
groups:
|
||||
ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
import { Flipper } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
@@ -17,8 +17,6 @@ import { Main } from "~/components/Main";
|
||||
import { Placeholder } from "~/components/Placeholder";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
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 { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
@@ -211,52 +209,14 @@ function Groups() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [_unseenMessages, setUnseenMessages] = React.useState(0);
|
||||
const [chatVisible, setChatVisible] = React.useState(false);
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
(data.ownGroup?.members ?? []).map((m) => [m.id, m]),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const rooms = React.useMemo(() => {
|
||||
return data.ownGroup?.chatCode
|
||||
? [
|
||||
{
|
||||
code: data.ownGroup.chatCode,
|
||||
label: "Group",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [data.ownGroup?.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;
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
const isMobile = width < 750;
|
||||
const isFullGroup =
|
||||
data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
|
||||
|
||||
const showChat = data.ownGroup && data.ownGroup.members.length > 1;
|
||||
|
||||
const invitedGroupsDesktop = (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>
|
||||
@@ -284,32 +244,9 @@ function Groups() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const chatElement = (
|
||||
<div>
|
||||
{showChat ? (
|
||||
<>
|
||||
<Chat
|
||||
rooms={rooms}
|
||||
users={chatUsers}
|
||||
className="w-full"
|
||||
messagesContainerClassName={styles.messagesContainer}
|
||||
chat={chat}
|
||||
onMount={onChatMount}
|
||||
onUnmount={onChatUnmount}
|
||||
/>
|
||||
{!isMobile ? (
|
||||
<div className="mt-4">{invitedGroupsDesktop}</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ownGroupElement = data.ownGroup ? (
|
||||
<div className="stack sm">
|
||||
{!showChat && (
|
||||
<ColumnHeader>{t("q:looking.columns.myGroup")}</ColumnHeader>
|
||||
)}
|
||||
<ColumnHeader>{t("q:looking.columns.myGroup")}</ColumnHeader>
|
||||
<GroupCard group={data.ownGroup} showNote ownGroup={data.ownGroup} />
|
||||
{data.ownGroup.inviteCode ? (
|
||||
<MemberAdder
|
||||
@@ -350,24 +287,8 @@ function Groups() {
|
||||
>
|
||||
{!isMobile ? (
|
||||
<div>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
{data.ownGroup && (
|
||||
<SendouTab id="own" number={data.ownGroup.members.length}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</SendouTab>
|
||||
)}
|
||||
{showChat && (
|
||||
<SendouTab id="chat" number={unseenMessages}>
|
||||
{t("q:looking.columns.chat")}
|
||||
</SendouTab>
|
||||
)}
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="own">{ownGroupElement}</SendouTabPanel>
|
||||
{data.ownGroup?.chatCode && (
|
||||
<SendouTabPanel id="chat">{chatElement}</SendouTabPanel>
|
||||
)}
|
||||
</SendouTabs>
|
||||
{ownGroupElement}
|
||||
{invitedGroupsDesktop}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.innerContainer}>
|
||||
@@ -393,11 +314,6 @@ function Groups() {
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</SendouTab>
|
||||
)}
|
||||
{isMobile && showChat && (
|
||||
<SendouTab id="chat" number={unseenMessages}>
|
||||
{t("q:looking.columns.chat")}
|
||||
</SendouTab>
|
||||
)}
|
||||
</SendouTabList>
|
||||
<SendouTabPanel id="groups">
|
||||
<div className="stack sm">
|
||||
@@ -457,7 +373,6 @@ function Groups() {
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="own">{ownGroupElement}</SendouTabPanel>
|
||||
<SendouTabPanel id="chat">{chatElement}</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</div>
|
||||
{!isMobile ? (
|
||||
|
||||
@@ -18,8 +18,6 @@ import { Image } from "~/components/Image";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
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";
|
||||
@@ -36,7 +34,6 @@ import {
|
||||
specialWeaponImageUrl,
|
||||
stageImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import tournamentStyles from "../../tournament/tournament.module.css";
|
||||
import type { Bracket } from "../core/Bracket";
|
||||
import * as Deadline from "../core/Deadline";
|
||||
import * as PickBan from "../core/PickBan";
|
||||
@@ -649,84 +646,12 @@ function StartedMatchTabs({
|
||||
const user = useUser();
|
||||
const tournament = useTournament();
|
||||
const data = useLoaderData<TournamentMatchLoaderData>();
|
||||
const [_unseenMessages, setUnseenMessages] = React.useState(0);
|
||||
const [chatVisible, setChatVisible] = React.useState(false);
|
||||
const [selectedTabKey, setSelectedTabKey] = useSearchParamState({
|
||||
defaultValue: "rosters",
|
||||
name: "tab",
|
||||
revive: (value) =>
|
||||
["chat", "rosters", "actions"].includes(value) ? value : null,
|
||||
revive: (value) => (["rosters", "actions"].includes(value) ? value : null),
|
||||
});
|
||||
|
||||
// TODO: resolve this on server (notice it is copy-pasted now)
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
[
|
||||
...data.match.players.map((p) => ({ ...p, title: undefined })),
|
||||
...(tournament.ctx.organization?.members ?? []).map((m) => ({
|
||||
...m,
|
||||
title: m.role === "STREAMER" ? "Cast" : "TO",
|
||||
})),
|
||||
...tournament.ctx.staff.map((s) => ({
|
||||
...s,
|
||||
title: s.role === "STREAMER" ? "Cast" : "TO",
|
||||
})),
|
||||
{
|
||||
...tournament.ctx.author,
|
||||
title: "TO",
|
||||
},
|
||||
].map((p) => [p.id, p]),
|
||||
);
|
||||
}, [data, tournament]);
|
||||
|
||||
const showChat = (() => {
|
||||
if (!data.match.chatCode) return false;
|
||||
if (tournament.ctx.isFinalized && !tournament.isOrganizer(user)) {
|
||||
return false;
|
||||
}
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
if (
|
||||
tournament.ctx.startTime < oneMonthAgo &&
|
||||
!tournament.isLeagueDivision
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
data.match.players.some((p) => p.id === user?.id) ||
|
||||
tournament.isOrganizerOrStreamer(user)
|
||||
);
|
||||
})();
|
||||
|
||||
const rooms = React.useMemo(() => {
|
||||
return showChat && data.match.chatCode
|
||||
? [
|
||||
{
|
||||
code: data.match.chatCode,
|
||||
label: "Match",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [showChat, 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];
|
||||
|
||||
const matchActionsKey = () =>
|
||||
@@ -756,33 +681,12 @@ function StartedMatchTabs({
|
||||
className={styles.matchTabs}
|
||||
>
|
||||
<SendouTabList>
|
||||
{showChat && (
|
||||
<SendouTab id="chat" number={unseenMessages} data-testid="chat-tab">
|
||||
Chat
|
||||
</SendouTab>
|
||||
)}
|
||||
<SendouTab id="rosters">Rosters</SendouTab>
|
||||
<SendouTab id="actions" data-testid="actions-tab">
|
||||
{presentational ? "Score" : "Actions"}
|
||||
</SendouTab>
|
||||
</SendouTabList>
|
||||
|
||||
<SendouTabPanel id="chat">
|
||||
<Chat
|
||||
rooms={rooms}
|
||||
users={chatUsers}
|
||||
className={tournamentStyles.chatContainer}
|
||||
messagesContainerClassName={clsx(
|
||||
tournamentStyles.chatMessagesContainer,
|
||||
"pt-0",
|
||||
)}
|
||||
chat={chat}
|
||||
onMount={onChatMount}
|
||||
onUnmount={onChatUnmount}
|
||||
missingUserName="???"
|
||||
/>
|
||||
</SendouTabPanel>
|
||||
|
||||
<SendouTabPanel id="rosters">
|
||||
<MatchRosters teams={[teams[0].id, teams[1].id]} />
|
||||
</SendouTabPanel>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import cachified from "@epic-web/cachified";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { setMetadata } from "~/features/chat/ChatSystemMessage.server";
|
||||
import { TOURNAMENT_MATCH_EXPIRY_MS } from "~/features/chat/chat-constants";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
@@ -92,6 +94,24 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
})
|
||||
: false;
|
||||
|
||||
// xxx: also need to check match is currently ongoing
|
||||
if (match.chatCode) {
|
||||
const playerIds = match.players.map((p) => p.id);
|
||||
// xxx: should this be resolved inside setMetadata?
|
||||
const chatUsers = await UserRepository.findChatUsersByUserIds(playerIds);
|
||||
|
||||
// xxx: module.function format
|
||||
setMetadata({
|
||||
chatCode: match.chatCode,
|
||||
header: "Tournament Match",
|
||||
subtitle: `Match #${matchId}`,
|
||||
url: `/to/${tournamentId}/matches/${matchId}`,
|
||||
participantUserIds: playerIds,
|
||||
chatUsers,
|
||||
expiresAt: Date.now() + TOURNAMENT_MATCH_EXPIRY_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
match,
|
||||
results,
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
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 { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
@@ -49,11 +47,6 @@ 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 ||
|
||||
|
||||
@@ -6,8 +6,6 @@ import { LinkButton } from "~/components/elements/Button";
|
||||
import { containerClassName } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
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";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
@@ -22,13 +20,9 @@ import { OrganizerMatchMapListDialog } from "../components/OrganizerMatchMapList
|
||||
import { StartedMatch } from "../components/StartedMatch";
|
||||
import { getRounds } from "../core/rounds";
|
||||
import { loader } from "../loaders/to.$id.matches.$mid.server";
|
||||
import {
|
||||
groupNumberToLetters,
|
||||
tournamentMatchWebsocketRoom,
|
||||
} from "../tournament-bracket-utils";
|
||||
import { groupNumberToLetters } from "../tournament-bracket-utils";
|
||||
export { action, loader };
|
||||
|
||||
import tournamentStyles from "../../tournament/tournament.module.css";
|
||||
import styles from "../tournament-bracket.module.css";
|
||||
|
||||
export default function TournamentMatchPage() {
|
||||
@@ -38,10 +32,7 @@ export default function TournamentMatchPage() {
|
||||
const tournament = useTournament();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
useWebsocketRevalidation({
|
||||
room: tournamentMatchWebsocketRoom(data.match.id),
|
||||
connected: !tournament.ctx.isFinalized,
|
||||
});
|
||||
// xxx: ensure tournament match chat can also be used before the match starts
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visibility !== "visible" || tournament.ctx.isFinalized) return;
|
||||
@@ -63,20 +54,6 @@ export default function TournamentMatchPage() {
|
||||
return type !== "EDIT";
|
||||
};
|
||||
|
||||
const showChatPeek = () => {
|
||||
if (!showRosterPeek()) return false;
|
||||
|
||||
if (tournament.isOrganizerOrStreamer(user)) return true;
|
||||
|
||||
const teamId = tournament.teamMemberOfByUser(user)?.id;
|
||||
if (!teamId) return false;
|
||||
|
||||
if (data.match.opponentOne?.id === teamId) return true;
|
||||
if (data.match.opponentTwo?.id === teamId) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("stack lg", containerClassName("normal"))}>
|
||||
<div className="flex horizontal justify-between items-center">
|
||||
@@ -130,61 +107,11 @@ export default function TournamentMatchPage() {
|
||||
teams={[data.match.opponentOne?.id, data.match.opponentTwo?.id]}
|
||||
/>
|
||||
) : null}
|
||||
{showChatPeek() ? <BeforeMatchChat /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BeforeMatchChat() {
|
||||
const tournament = useTournament();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
// TODO: resolve this on server (notice it is copy-pasted now)
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
[
|
||||
...data.match.players.map((p) => ({ ...p, title: undefined })),
|
||||
...(tournament.ctx.organization?.members ?? []).map((m) => ({
|
||||
...m,
|
||||
title: m.role === "STREAMER" ? "Cast" : "TO",
|
||||
})),
|
||||
...tournament.ctx.staff.map((s) => ({
|
||||
...s,
|
||||
title: s.role === "STREAMER" ? "Cast" : "TO",
|
||||
})),
|
||||
{
|
||||
...tournament.ctx.author,
|
||||
title: "TO",
|
||||
},
|
||||
].map((p) => [p.id, p]),
|
||||
);
|
||||
}, [data, tournament]);
|
||||
|
||||
const rooms = React.useMemo(() => {
|
||||
return data.match.chatCode
|
||||
? [
|
||||
{
|
||||
code: data.match.chatCode,
|
||||
label: "Match",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [data.match.chatCode]);
|
||||
|
||||
return (
|
||||
<div className={clsx(tournamentStyles.actionSection, "mt-6")}>
|
||||
<ConnectedChat
|
||||
rooms={rooms}
|
||||
users={chatUsers}
|
||||
className={tournamentStyles.chatContainer}
|
||||
messagesContainerClassName={tournamentStyles.chatMessagesContainer}
|
||||
missingUserName="???"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchHeader() {
|
||||
const tournament = useTournament();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Layout } from "./components/layout";
|
||||
import { Ramp } from "./components/ramp/Ramp";
|
||||
import { getUser } from "./features/auth/core/user.server";
|
||||
import { userMiddleware } from "./features/auth/core/user-middleware.server";
|
||||
import { ChatProvider } from "./features/chat/ChatProvider";
|
||||
import { getSidenavSession } from "./features/layout/core/sidenav-session.server";
|
||||
import { sessionIdMiddleware } from "./features/session-id/session-id-middleware.server";
|
||||
import {
|
||||
@@ -193,7 +194,9 @@ function Document({
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<SendouToastRegion />
|
||||
<MyRamp data={data} />
|
||||
<Layout data={data}>{children}</Layout>
|
||||
<ChatProvider user={data?.user}>
|
||||
<Layout data={data}>{children}</Layout>
|
||||
</ChatProvider>
|
||||
</I18nProvider>
|
||||
</RouterProvider>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -236,6 +236,7 @@ export default [
|
||||
]),
|
||||
|
||||
route("/admin", "features/admin/routes/admin.tsx"),
|
||||
route("/api/chat-users", "features/chat/routes/api.chat-users.ts"),
|
||||
route("/api", "features/api/routes/api.tsx"),
|
||||
|
||||
...prefix("/a", [
|
||||
|
||||
@@ -309,6 +309,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -309,6 +309,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -309,6 +309,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmed canceling the match. Match is now locked",
|
||||
"chat.systemMsg.userLeft": "{{name}} left the group",
|
||||
"chat.newMessages": "New messages",
|
||||
"chat.sidebar.title": "Chat",
|
||||
"chat.sidebar.noActiveChats": "No active chats",
|
||||
"fc.title": "Friend code",
|
||||
"fc.helpText": "Used to verify your identity for sendou.ink staff, tournament organizers, and SendouQ opponents. You must always play only on this one account.",
|
||||
"fc.whereToFind": "Find your friend code on your Nintendo Switch (2)",
|
||||
|
||||
@@ -311,6 +311,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada",
|
||||
"chat.systemMsg.userLeft": "{{name}} abandonó el grupo",
|
||||
"chat.newMessages": "Nuevos mensajes",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "Clave de amigo",
|
||||
"fc.helpText": "Se usa para verificar tu identidad ante el staff de sendou.ink, organizadores de torneos y oponentes de SendouQ. Debes jugar siempre con esta misma cuenta.",
|
||||
"fc.whereToFind": "Encuentra tu clave de amigo en tu Nintendo Switch (2)",
|
||||
@@ -381,6 +383,5 @@
|
||||
"header.parameter": "",
|
||||
"weaponArt.title": "",
|
||||
"tier.tentative": "Tier {{tierName}} provisional",
|
||||
"tier.confirmed": "Tier {{tierName}} confirmado",
|
||||
"header.login": "Acceso"
|
||||
"tier.confirmed": "Tier {{tierName}} confirmado"
|
||||
}
|
||||
|
||||
@@ -220,6 +220,5 @@
|
||||
"errors.alreadyFriends": "",
|
||||
"errors.friendRequestExists": "",
|
||||
"labels.type": "",
|
||||
"labels.visibility": "",
|
||||
"labels.vodType": "Tipo"
|
||||
"labels.visibility": ""
|
||||
}
|
||||
|
||||
@@ -43,9 +43,5 @@
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": "",
|
||||
"rotations.remaining": "",
|
||||
"showcase.tabs.signedUp": "Inscrito",
|
||||
"showcase.tabs.organizer": "Organizando",
|
||||
"showcase.tabs.discover": "Descubrir",
|
||||
"showcase.viewAll": "Ver todos los torneos"
|
||||
"rotations.remaining": ""
|
||||
}
|
||||
|
||||
@@ -163,12 +163,5 @@
|
||||
"tiers.info.p3": "Cada rango también tiene un nivel plus (ver BRONCE+ de ejemplo). Significa que estás en el 50% superior de ese rango.",
|
||||
"streams.noStreams": "No hay streams de partidos recientes",
|
||||
"streams.ownStreamInfo": "¿Falta tu stream? Asegura que tu cuenta de Twitch esté conectada y que tu juego es Splatoon 3.",
|
||||
"streams.ownStreamInfo.linkText": "Ver el FAQ para más información como sobre conectar tu cuenta de Twitch.",
|
||||
"front.join.joinWithTrustAction": "Unirte y confiar de {{inviterName}}",
|
||||
"front.join.joinWithTrustAction.explanation": "Confiar de un usuario le permite agregarte a un grupo sin enviar invitación en el futuro",
|
||||
"settings.trusted.header": "Usuarios de confianza",
|
||||
"settings.trusted.confirm": "¿Quitar {{name}} de los usuarios de confianza?",
|
||||
"settings.trusted.trustedExplanation": "Los usuarios de confianza pueden añadirte directamente a grupos y equipos de torneos. Si te eliminan de esta lista, en el futuro tendrás que unirte a través de un enlace que ellos compartan.",
|
||||
"settings.trusted.noTrustedExplanation": "Actualmente no confías en ningún usuario. Puedes confiar en usuarios cuando te unes a su grupo SendouQ o equipo de torneo a través de un enlace. Los usuarios de confianza pueden agregarte a grupos y equipos de torneos directamente.",
|
||||
"settings.trusted.teamExplanation": "Además de los usuarios anteriores, los miembros de tu equipo <2>{{name}}</2> pueden agregarte directamente."
|
||||
"streams.ownStreamInfo.linkText": "Ver el FAQ para más información como sobre conectar tu cuenta de Twitch."
|
||||
}
|
||||
|
||||
@@ -151,7 +151,5 @@
|
||||
"progression.error.NEGATIVE_PROGRESSION": "La progresión negativa solo es posible en eliminación doble",
|
||||
"progression.error.NO_SE_SOURCE": "La eliminación simple no es un cuadro de origen válido",
|
||||
"progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro",
|
||||
"pre.roster.addTrusted.header": "Agregar personas con quienes has jugado",
|
||||
"join.giveTrust": "¿Confiar a {{name}} para añadirte a torneos futuros?"
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro"
|
||||
}
|
||||
|
||||
@@ -197,29 +197,5 @@
|
||||
"mutualFriends": "",
|
||||
"mutualFriends.count_one": "",
|
||||
"mutualFriends.count_many": "",
|
||||
"mutualFriends.count_other": "",
|
||||
"country": "País",
|
||||
"bio": "Biografía",
|
||||
"pronoun": "Pronombres",
|
||||
"pronounsInfo": "¡Este ajuste es opcional! Tus pronombres se mostrarán en tu perfil, plantillas de torneos, grupos de SendouQ y canales de texto.",
|
||||
"weaponPool": "Grupo de armas",
|
||||
"favoriteBadges": "Insignias favoritas",
|
||||
"battlefy": "Nombre de cuenta de Battlefy",
|
||||
"forms.newProfileEnabled": "Nueva página de perfil",
|
||||
"forms.newProfileEnabled.info": "Activa la nueva página de perfil basada en widgets. Actualmente disponible como acceso anticipado para mecenas.",
|
||||
"forms.showDiscordUniqueName": "Mostrar usuario de Discord",
|
||||
"forms.showDiscordUniqueName.info": "¿Mostrar tu nombre de Discord ({{discordUniqueName}}) publicamente?",
|
||||
"forms.commissionsOpen": "Comisiones abiertas",
|
||||
"forms.commissionsOpen.info": "Las comisiones se cierran automáticamente y deben volver a activarse al cabo de un mes para evitar que los anuncios queden obsoletos.",
|
||||
"forms.commissionText": "Info de comisiones",
|
||||
"forms.commissionText.info": "Precio, espacios abiertos, o cualquier otra información sobre tus comisiones.",
|
||||
"forms.customName.info": "Si se deja en blanco, se utilizará tu nombre de Discord: \"{{discordName}}\"",
|
||||
"forms.country.search.placeholder": "Buscar países",
|
||||
"forms.favoriteBadges.nonSupporter": "Hazte mecenas para elegir qué insignias aparecen en la primera página y en qué orden",
|
||||
"forms.errors.invalidCustomUrl.numbers": "Enlace personalizado no puede ser solo numeros",
|
||||
"forms.errors.invalidCustomUrl.strangeCharacter": "Enlace personalizado no puede contener caracteres especiales",
|
||||
"forms.errors.invalidCustomUrl.duplicate": "Alguien ya tiene ese enlace personalizado",
|
||||
"forms.errors.invalidSens": "Motion sens can't be set if R-stick sens isn't",
|
||||
"forms.info.customUrl": "Para los mecenas (nivel Supporter o superior) hay un enlace acortado disponible. Ej. en lugar de sendou.ink/u/sendou, se puede usar snd.ink/sendou.",
|
||||
"forms.info.battlefy": "El nombre de cuenta de Battlefy se usa para el posicionamiento (seeding) y la verificación en algunos torneos"
|
||||
"mutualFriends.count_other": ""
|
||||
}
|
||||
|
||||
@@ -311,6 +311,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar el partido. El partido está cerrado",
|
||||
"chat.systemMsg.userLeft": "{{name}} se salió del grupo",
|
||||
"chat.newMessages": "Nuevo mensajes",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "Clave de amigo",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -311,6 +311,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -311,6 +311,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} a confirmé l'anunulation du match. Le match est maintenant vérrouillé",
|
||||
"chat.systemMsg.userLeft": "{{name}} a quitté le groupe",
|
||||
"chat.newMessages": "Nouveau message",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "Code ami ",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -310,6 +310,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -311,6 +311,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} ha confermato la cancellazione del match. Il match è ora bloccato",
|
||||
"chat.systemMsg.userLeft": "{{name}} ha lasciato il gruppo",
|
||||
"chat.newMessages": "Nuovi messaggi",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "Codice amico",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -305,6 +305,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} が試合キャンセルを承認しました。試合がロックされました。",
|
||||
"chat.systemMsg.userLeft": "{{name}} がグループから出ました",
|
||||
"chat.newMessages": "新着メッセージ",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "フレンドコード",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -305,6 +305,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -309,6 +309,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -312,6 +312,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.userLeft": "",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -311,6 +311,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmou o cancelamento da partida. A partida foi trancada",
|
||||
"chat.systemMsg.userLeft": "{{name}} deixou o grupo",
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "Código de amigo",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -312,6 +312,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} подтвердил отмену матча. Матч теперь закрыт",
|
||||
"chat.systemMsg.userLeft": "{{name}} покинул группу",
|
||||
"chat.newMessages": "Новые сообщения",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "Код друга",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
@@ -305,6 +305,8 @@
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} 确认取消对战,本次对战已锁定",
|
||||
"chat.systemMsg.userLeft": "{{name}} 离开了小队",
|
||||
"chat.newMessages": "新消息",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"fc.title": "好友码",
|
||||
"fc.helpText": "",
|
||||
"fc.whereToFind": "",
|
||||
|
||||
Reference in New Issue
Block a user