Chat sidebar work

This commit is contained in:
Kalle 2026-03-12 19:37:42 +02:00
parent d884cb3f3b
commit a2a2de9bcb
12 changed files with 231 additions and 134 deletions

View File

@ -160,6 +160,29 @@
flex-shrink: 0;
}
.chatHeaderInfo {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.chatHeaderLink {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
text-decoration: none;
color: inherit;
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
transition: background-color 0.15s;
}
.chatHeaderLink:hover {
background-color: var(--color-bg-higher);
}
.chatHeaderTitle {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
@ -168,6 +191,14 @@
white-space: nowrap;
}
.chatHeaderSubtitle {
font-size: var(--font-2xs);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
padding: var(--s-4);
text-align: center;

View File

@ -1,12 +1,12 @@
import { ArrowLeft, MessageSquare, X } from "lucide-react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Chat } from "~/features/chat/components/Chat";
import { useChatContext } from "~/features/chat/useChatContext";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import styles from "./ChatSidebar.module.css";
// 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 chatContext = useChatContext();
@ -103,15 +103,30 @@ function ChatView({ onClose }: { onClose?: () => void }) {
chatContext.setActiveRoom(null);
};
const headerContent = (
<>
<span className={styles.chatHeaderTitle}>
{room?.header ?? activeRoom}
</span>
{room?.subtitle ? (
<span className={styles.chatHeaderSubtitle}>{room.subtitle}</span>
) : null}
</>
);
return (
<div className={styles.sidebar}>
<div className={styles.chatHeader}>
<Button className={styles.backButton} onPress={handleBack}>
<ArrowLeft size={18} />
</Button>
<span className={styles.chatHeaderTitle}>
{room?.header ?? activeRoom}
</span>
{room?.url ? (
<Link to={room.url} className={styles.chatHeaderLink}>
{headerContent}
</Link>
) : (
<div className={styles.chatHeaderInfo}>{headerContent}</div>
)}
{onClose ? (
<Button className={styles.closeButton} onPress={onClose}>
<X size={18} />

View File

@ -24,21 +24,6 @@ export function TopRightButtons({
}) {
const { t } = useTranslation(["common", "front"]);
// xxx: anti-pattern? probablty just extract this
const chatButton = (variant: "outlined" | "primary", onPress: () => void) => (
<>
<SendouButton
size="small"
icon={<MessageSquare />}
variant={variant}
onPress={onPress}
/>
{chatUnreadCount ? (
<span className={styles.chatUnreadBadge}>{chatUnreadCount}</span>
) : null}
</>
);
return (
<div className={styles.container}>
{showSupport ? (
@ -65,12 +50,20 @@ export function TopRightButtons({
</div>
{onChatToggle ? (
<div className={styles.chatButtonWrapperPersistent}>
{chatButton("outlined", onChatToggle)}
<ChatButton
variant="outlined"
onPress={onChatToggle}
unreadCount={chatUnreadCount}
/>
</div>
) : null}
{onChatModalToggle ? (
<div className={styles.chatButtonWrapperModal}>
{chatButton("outlined", onChatModalToggle)}
<ChatButton
variant="outlined"
onPress={onChatModalToggle}
unreadCount={chatUnreadCount}
/>
</div>
) : null}
</>
@ -84,3 +77,27 @@ export function TopRightButtons({
</div>
);
}
function ChatButton({
variant,
onPress,
unreadCount,
}: {
variant: "outlined" | "primary";
onPress: () => void;
unreadCount?: number;
}) {
return (
<>
<SendouButton
size="small"
icon={<MessageSquare />}
variant={variant}
onPress={onPress}
/>
{unreadCount ? (
<span className={styles.chatUnreadBadge}>{unreadCount}</span>
) : null}
</>
);
}

View File

@ -1,7 +1,7 @@
import { nanoid } from "nanoid";
import { WebSocket } from "partysocket";
import * as React from "react";
import { useMatches, useRevalidator } from "react-router";
import { useFetcher, useMatches, useRevalidator } from "react-router";
import { logger } from "~/utils/logger";
import { soundPath } from "~/utils/urls";
import type {
@ -15,7 +15,6 @@ 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 {
@ -91,9 +90,6 @@ function ChatProviderInner({
>({});
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> = {};
@ -105,36 +101,13 @@ function ChatProviderInner({
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);
logger.debug("WS message received:", parsed);
// Initial rooms payload on connect
if (parsed.rooms && Array.isArray(parsed.rooms)) {
logger.debug("WS initial rooms payload, count:", parsed.rooms.length);
const serverRooms = parsed.rooms as ServerRoomInfo[];
const roomList = serverRooms.map(flattenServerRoom);
setRooms(roomList);
@ -150,6 +123,7 @@ function ChatProviderInner({
// ROOM_JOINED: new room added
if (parsed.event === "ROOM_JOINED" && parsed.room) {
logger.debug("WS ROOM_JOINED:", parsed.room?.chatCode);
const serverRoom = parsed.room as ServerRoomInfo;
const newRoom = flattenServerRoom(serverRoom);
setRooms((prev) => {
@ -167,6 +141,12 @@ function ChatProviderInner({
// CHAT_HISTORY response (also returned by SUBSCRIBE with metadata)
if (parsed.event === "CHAT_HISTORY" && Array.isArray(parsed.messages)) {
logger.debug(
"WS CHAT_HISTORY for:",
parsed.chatCode,
"messages:",
parsed.messages.length,
);
const chatCode = parsed.chatCode as string;
const messages = parsed.messages as ChatMessage[];
setMessagesByRoom((prev) => ({
@ -206,6 +186,12 @@ function ChatProviderInner({
) as (ChatMessage & { totalMessageCount?: number })[];
const isSystemMessage = Boolean(messageArr[0].type);
logger.debug(
"WS message(s):",
messageArr.length,
"system:",
isSystemMessage,
);
if (isSystemMessage) {
revalidate();
}
@ -238,10 +224,6 @@ function ChatProviderInner({
return { ...prev, [roomCode]: [...existing, msg] };
});
if (msg.userId && !chatUsersCache[msg.userId]) {
fetchUnknownUsers([msg.userId]);
}
if (msg.totalMessageCount) {
setRooms((prev) =>
prev.map((r) =>
@ -396,10 +378,21 @@ function ChatProviderInner({
if (open && activeRoom) {
markAsRead(activeRoom);
}
if (open && rooms.length === 1 && !activeRoom) {
requestHistory(rooms[0].chatCode);
setActiveRoom(rooms[0].chatCode);
}
},
[activeRoom, markAsRead],
[activeRoom, markAsRead, requestHistory, rooms.length, rooms[0]?.chatCode],
);
useFetchUnknownChatUsers({
messages: messagesByRoom,
chatUsersCache,
setChatUsersCache,
});
useChatRouteSync({
rooms,
userId,
@ -452,7 +445,6 @@ function ChatProviderInner({
);
}
// xxx: bug: when viewing as non-participant can't go back to list view
// xxx: bug: room should close automatically if non-participant and leaves the route
// xxx: bug: route loading state should show before room metadata loads
function useChatRouteSync({
@ -476,20 +468,11 @@ function useChatRouteSync({
React.SetStateAction<Record<string, ChatMessage[]>>
>;
}) {
const matches = useMatches();
const chatCode = useCurrentRouteChatCode();
const subscribedRoomRef = React.useRef<string | null>(null);
const previousRouteChatCodeRef = 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;
}
}
const previousSubscribed = subscribedRoomRef.current;
// Clean up previous non-participant subscription if chatCode changed
@ -500,10 +483,15 @@ function useChatRouteSync({
const { [previousSubscribed]: _, ...rest } = prev;
return rest;
});
setActiveRoom(null);
setSidebarOpen(false);
subscribedRoomRef.current = null;
}
if (!chatCode) return;
if (!chatCode) {
previousRouteChatCodeRef.current = null;
return;
}
const room = rooms.find((r) => r.chatCode === chatCode);
const isParticipant = room?.participantUserIds.includes(userId);
@ -513,10 +501,15 @@ function useChatRouteSync({
subscribedRoomRef.current = chatCode;
}
setActiveRoom(chatCode);
setSidebarOpen(true);
const routeChatCodeChanged = previousRouteChatCodeRef.current !== chatCode;
previousRouteChatCodeRef.current = chatCode;
if (routeChatCodeChanged) {
setActiveRoom(chatCode);
setSidebarOpen(true);
}
}, [
matches,
chatCode,
rooms,
userId,
setActiveRoom,
@ -527,3 +520,54 @@ function useChatRouteSync({
setMessagesByRoom,
]);
}
function useCurrentRouteChatCode() {
const matches = useMatches();
for (const match of matches) {
const matchData = match.data as { chatCode?: string } | undefined;
if (matchData?.chatCode) {
return matchData.chatCode;
}
}
return null;
}
function useFetchUnknownChatUsers({
messages,
chatUsersCache,
setChatUsersCache,
}: {
messages: Record<string, ChatMessage[]>;
chatUsersCache: Record<number, ChatUser>;
setChatUsersCache: React.Dispatch<
React.SetStateAction<Record<number, ChatUser>>
>;
}) {
const fetcher = useFetcher<Record<number, ChatUser>>();
const unknownIds: number[] = [];
for (const msgs of Object.values(messages)) {
for (const msg of msgs) {
if (msg.userId && !chatUsersCache[msg.userId]) {
unknownIds.push(msg.userId);
}
}
}
const idsParam = unknownIds.sort((a, b) => a - b).join(",");
React.useEffect(() => {
if (!idsParam || fetcher.state !== "idle") return;
logger.debug(`Fetching unknown chat users: ${idsParam}`);
fetcher.load(`/api/chat-users?ids=${idsParam}`);
}, [idsParam, fetcher.load, fetcher.state]);
React.useEffect(() => {
if (!fetcher.data) return;
setChatUsersCache((prev) => ({ ...prev, ...fetcher.data }));
}, [fetcher.data, setChatUsersCache]);
}

View File

@ -1,8 +1,10 @@
import { add } from "date-fns";
import { nanoid } from "nanoid";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import type { ChatMessage, ChatUser } from "./chat-types";
import type { ChatMessage } from "./chat-types";
const SKALOP_TOKEN_HEADER_NAME = "Skalop-Token";
@ -64,8 +66,7 @@ interface SetMetadataArgs {
subtitle: string;
url: string;
participantUserIds: number[];
chatUsers: Record<number, ChatUser>;
expiresAt: number;
expiresAfter: { hours: number } | { days: number };
}
// xxx: actually there is no dedup like this, for as long as the service is up, we dont resend metadata
@ -73,7 +74,7 @@ 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) {
export async function setMetadata(args: SetMetadataArgs) {
if (systemMessagesDisabled) return;
if (!process.env.SKALOP_SYSTEM_MESSAGE_URL) return;
@ -89,6 +90,12 @@ export function setMetadata(args: SetMetadataArgs) {
}
}
const expiresAt = add(new Date(), args.expiresAfter).getTime();
const chatUsers = await UserRepository.findChatUsersByUserIds(
args.participantUserIds,
);
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL, {
method: "POST",
body: JSON.stringify({
@ -96,8 +103,8 @@ export function setMetadata(args: SetMetadataArgs) {
chatCode: args.chatCode,
metadata: {
participantUserIds: args.participantUserIds,
chatUsers: args.chatUsers,
expiresAt: args.expiresAt,
chatUsers,
expiresAt,
header: args.header,
subtitle: args.subtitle,
url: args.url,

View File

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

@ -12,7 +12,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return Response.json({});
}
// xxx: use zod
const parsedIds = idsParam
.split(",")
.map(Number)

View File

@ -1,6 +1,5 @@
import type { LoaderFunctionArgs } from "react-router";
import { setMetadata } from "~/features/chat/ChatSystemMessage.server";
import { SCRIM_EXPIRY_MS } from "~/features/chat/chat-constants";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "../../../utils/remix.server";
@ -27,26 +26,23 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
}
const participantIds = Scrim.participantIdsListFromAccepted(post);
const chatUsers = await UserRepository.findChatUsersByUserIds(participantIds);
// xxx: additional condition
if (post.chatCode) {
setMetadata({
ChatSystemMessage.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,
expiresAfter: { hours: 3 },
});
}
return {
post,
// xxx: only chatCode if permissions
chatCode: post.chatCode,
chatCode: user.roles.includes("STAFF") ? post.chatCode : undefined,
anyUserPrefersNoScreen:
await UserRepository.anyUserPrefersNoScreen(participantIds),
tournamentMapPool: post.mapsTournament

View File

@ -1,14 +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 * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
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 { sendouQMatchPage } from "~/utils/urls";
import { qMatchPageParamsSchema } from "../q-match-schemas";
export const loader = async ({ params }: LoaderFunctionArgs) => {
@ -35,24 +34,19 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
? await ReportedWeaponRepository.findByMatchId(matchId)
: null;
// xxx: also if match is even ongoing
if (match.chatCode && user) {
if (match.chatCode && !match.isLocked) {
const participantIds = [
...matchUnmapped.groupAlpha.members,
...matchUnmapped.groupBravo.members,
].map((m) => m.id);
const chatUsers =
await UserRepository.findChatUsersByUserIds(participantIds);
setMetadata({
ChatSystemMessage.setMetadata({
chatCode: match.chatCode,
header: "SQ Match",
subtitle: `Match #${matchId}`,
url: `/q/match/${matchId}`,
header: `Match #${matchId}`,
subtitle: "SendouQ",
url: sendouQMatchPage(matchId),
participantUserIds: participantIds,
chatUsers,
expiresAt: Date.now() + SENDOUQ_MATCH_EXPIRY_MS,
expiresAfter: { hours: 2 },
});
}
@ -67,7 +61,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
})
: null,
rawReportedWeapons,
// xxx: only chatCode if permissions
chatCode: match.chatCode,
chatCode: user?.roles.includes("STAFF") ? match.chatCode : null,
};
};

View File

@ -1,10 +1,8 @@
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 ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
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";
@ -35,19 +33,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
});
}
// xxx: how to handle group changing..?
if (ownGroup?.chatCode) {
const memberIds = ownGroup.members.map((m: { id: number }) => m.id);
const chatUsers = await UserRepository.findChatUsersByUserIds(memberIds);
setMetadata({
ChatSystemMessage.setMetadata({
chatCode: ownGroup.chatCode,
header: "SQ Group",
// xxx: better subtitle
subtitle: "Looking for match",
header: "Group",
subtitle: "SendouQ",
url: "/q/looking",
participantUserIds: memberIds,
chatUsers,
expiresAt: Date.now() + SENDOUQ_MATCH_EXPIRY_MS,
expiresAfter: { hours: 1 },
});
}

View File

@ -1,7 +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 { getUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
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";
@ -9,7 +9,9 @@ import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { tournamentMatchPage } from "~/utils/urls";
import { mapListFromResults, resolveMapList } from "../core/mapList.server";
import { tournamentFromDBCached } from "../core/Tournament.server";
import { findMatchById } from "../queries/findMatchById.server";
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
import { matchPageParamsSchema } from "../tournament-bracket-schemas.server";
@ -22,6 +24,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
params,
schema: matchPageParamsSchema,
});
const user = getUser();
const tournament = await tournamentFromDBCached({
tournamentId,
user: undefined,
});
const match = notFoundIfFalsy(findMatchById(matchId));
@ -94,21 +101,17 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
})
: false;
// xxx: also need to check match is currently ongoing
if (match.chatCode) {
if (match.chatCode && !matchIsOver) {
const playerIds = match.players.map((p) => p.id);
// xxx: should this be resolved inside setMetadata?
const chatUsers = await UserRepository.findChatUsersByUserIds(playerIds);
const matchContext = tournament.matchContextNamesById(matchId);
// xxx: module.function format
setMetadata({
ChatSystemMessage.setMetadata({
chatCode: match.chatCode,
header: "Tournament Match",
subtitle: `Match #${matchId}`,
url: `/to/${tournamentId}/matches/${matchId}`,
header: matchContext.roundName ?? `Match #${matchId}`,
subtitle: tournament.ctx.name,
url: tournamentMatchPage({ tournamentId, matchId }),
participantUserIds: playerIds,
chatUsers,
expiresAt: Date.now() + TOURNAMENT_MATCH_EXPIRY_MS,
expiresAfter: { hours: 2 },
});
}
@ -119,7 +122,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
matchIsOver,
endedEarly,
noScreen,
// xxx: only chatCode if permissions
chatCode: match.chatCode,
chatCode: tournament.isOrganizerOrStreamer(user)
? match.chatCode
: undefined,
};
};

View File

@ -22,4 +22,5 @@ export const logger = {
info: (...args: unknown[]) => console.log(...formatLog(...args)),
error: (...args: unknown[]) => console.error(...formatLog(...args)),
warn: (...args: unknown[]) => console.warn(...formatLog(...args)),
debug: (...args: unknown[]) => console.debug(...formatLog(...args)),
};