This commit is contained in:
Kalle
2026-03-11 20:18:36 +02:00
parent 8df04753ea
commit b063b9300a
50 changed files with 898 additions and 835 deletions

View File

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

View File

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

View File

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

View File

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

View 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,
]);
}

View File

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

View File

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

View File

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

View 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;
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -309,6 +309,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.userLeft": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"fc.title": "",
"fc.helpText": "",
"fc.whereToFind": "",

View File

@@ -309,6 +309,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.userLeft": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"fc.title": "",
"fc.helpText": "",
"fc.whereToFind": "",

View File

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

View File

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

View File

@@ -220,6 +220,5 @@
"errors.alreadyFriends": "",
"errors.friendRequestExists": "",
"labels.type": "",
"labels.visibility": "",
"labels.vodType": "Tipo"
"labels.visibility": ""
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -311,6 +311,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.userLeft": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"fc.title": "",
"fc.helpText": "",
"fc.whereToFind": "",

View File

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

View File

@@ -310,6 +310,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.userLeft": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"fc.title": "",
"fc.helpText": "",
"fc.whereToFind": "",

View File

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

View File

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

View File

@@ -305,6 +305,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.userLeft": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"fc.title": "",
"fc.helpText": "",
"fc.whereToFind": "",

View File

@@ -309,6 +309,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.userLeft": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"fc.title": "",
"fc.helpText": "",
"fc.whereToFind": "",

View File

@@ -312,6 +312,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.userLeft": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"fc.title": "",
"fc.helpText": "",
"fc.whereToFind": "",

View File

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

View File

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

View File

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