-
+
{parts}>;
}
-function MessageTimestamp({ timestamp }: { timestamp: number }) {
+function MessageTimestamp({ createdAt }: { createdAt: number }) {
const { formatter: dateTimeFormatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
@@ -372,13 +396,14 @@ function MessageTimestamp({ timestamp }: { timestamp: number }) {
hour: "numeric",
minute: "numeric",
});
- const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
+ const date = databaseTimestampToDate(createdAt);
+ const moreThanDayAgo = sub(new Date(), { days: 1 }) > date;
return (
);
}
diff --git a/app/features/chat/revalidate-broadcast-throttle.ts b/app/features/chat/revalidate-broadcast-throttle.ts
index b309099c3..ae1c2b3f5 100644
--- a/app/features/chat/revalidate-broadcast-throttle.ts
+++ b/app/features/chat/revalidate-broadcast-throttle.ts
@@ -1,15 +1,17 @@
-import type { ChatMessage } from "./chat-types";
+import type { RevalidateScope, SystemMessageType } from "./chat-types";
import { messageTypeToSound } from "./chat-utils";
-type ThrottleableMessage = Pick<
- ChatMessage,
- "room" | "type" | "revalidateOnly" | "revalidateScope"
->;
+interface ThrottleableMessage {
+ room: string;
+ type?: SystemMessageType;
+ revalidateOnly?: boolean;
+ revalidateScope?: RevalidateScope;
+}
interface ThrottleEntry {
lastSentAt: number;
trailing: {
- scope: ChatMessage["revalidateScope"];
+ scope: RevalidateScope | undefined;
timer: ReturnType;
} | null;
}
@@ -46,7 +48,7 @@ export function createRevalidateBroadcastThrottle({
/** Delivers the coalesced trailing broadcast of a window. */
sendTrailing: (msg: {
room: string;
- revalidateScope: ChatMessage["revalidateScope"];
+ revalidateScope: RevalidateScope | undefined;
}) => void;
}) {
const entries = new Map();
@@ -65,7 +67,9 @@ export function createRevalidateBroadcastThrottle({
* Whether the throttle applies to the message: a revalidation broadcast carrying
* no sound. Real chat messages always pass through untouched.
*/
- throttles(msg: Pick): boolean {
+ throttles(
+ msg: Pick,
+ ): boolean {
return Boolean(msg.revalidateOnly) && !messageTypeToSound(msg.type);
},
handle(msg: ThrottleableMessage): void {
diff --git a/app/features/chat/routes/api.chat.rooms.ts b/app/features/chat/routes/api.chat.rooms.ts
index f7dee9c66..00405ed57 100644
--- a/app/features/chat/routes/api.chat.rooms.ts
+++ b/app/features/chat/routes/api.chat.rooms.ts
@@ -1,34 +1,40 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatRepository from "../ChatRepository.server";
import * as ChatRoomResolver from "../ChatRoomResolver.server";
+import type { ChatRoomListItem } from "../chat-types";
/**
* The user's open chat rooms with server-computed unread counts. A background
* resource like the notifications peek: fetched after mount and refetched on
* `chatMessage` / `roomsChanged` events instead of riding any page loader.
*/
-export const loader = async () => {
+export const loader = async (): Promise<{ rooms: ChatRoomListItem[] }> => {
const user = requireUser();
const rooms = await ChatRoomResolver.findAllByUserId(user.id);
- const unreadCounts = await ChatRepository.findUnreadCountsByRoomIds(
+ const messageStats = await ChatRepository.findMessageStatsByRoomIds(
user.id,
rooms.map((room) => room.roomId),
);
- const unreadCountByRoomId = new Map(
- unreadCounts.map((row) => [row.roomId, row.unreadCount]),
- );
+ const statsByRoomId = new Map(messageStats.map((row) => [row.roomId, row]));
return {
- rooms: rooms.map((room) => ({
- id: room.roomId,
- type: room.type,
- titleParams: room.titleParams,
- url: room.url,
- imageUrl: room.imageUrl,
- participantUserIds: room.participantUserIds,
- expiresAt: room.expiresAt,
- unreadCount: unreadCountByRoomId.get(room.roomId) ?? 0,
- })),
+ rooms: rooms.map((room) => {
+ const stats = statsByRoomId.get(room.roomId);
+
+ return {
+ id: room.roomId,
+ type: room.type,
+ titleParams: room.titleParams,
+ url: room.url,
+ imageUrl: room.imageUrl,
+ participantUserIds: room.participantUserIds,
+ expiresAt: room.expiresAt,
+ inactive: room.inactive,
+ unreadCount: stats?.unreadCount ?? 0,
+ latestMessageId: stats?.latestMessageId ?? null,
+ latestMessageAt: stats?.latestMessageCreatedAt ?? null,
+ };
+ }),
};
};
diff --git a/app/features/chat/routes/chat-routes.test.ts b/app/features/chat/routes/chat-routes.test.ts
index 458a30b33..bc66f5a6c 100644
--- a/app/features/chat/routes/chat-routes.test.ts
+++ b/app/features/chat/routes/chat-routes.test.ts
@@ -182,6 +182,24 @@ describe("chat rooms loader", () => {
expect(matchRoom?.participantUserIds).toHaveLength(8);
});
+ test("exposes the room's inactive flag and latest message stats", async () => {
+ const { match, alphaUserIds, bravoUserIds } = await setupSqMatch(users);
+ const message = await sendMessageOk(alphaUserIds[0], match.chatRoomId!, {
+ publicId: "oooooooooo",
+ contents: "hello",
+ });
+ await ChatRepository.updateRoomsInactive([match.chatRoomId], true);
+
+ const data = await loadRooms(bravoUserIds[0]);
+ const matchRoom = data.rooms.find((room) => room.id === match.chatRoomId);
+
+ expect(matchRoom).toMatchObject({
+ inactive: true,
+ latestMessageId: message.id,
+ latestMessageAt: message.createdAt,
+ });
+ });
+
test("does not count the sender's own message as unread on their other devices", async () => {
const { match, alphaUserIds } = await setupSqMatch(users);
await sendMessageOk(alphaUserIds[0], match.chatRoomId!, {
diff --git a/app/features/events/core/EventBus.server.ts b/app/features/events/core/EventBus.server.ts
index 0f16c3662..84b64a6f1 100644
--- a/app/features/events/core/EventBus.server.ts
+++ b/app/features/events/core/EventBus.server.ts
@@ -1,17 +1,8 @@
import type { ServerEvent } from "../events-types";
+export { chatRoomChannel, userChannel } from "../events-types";
export type { ServerEvent };
-/** Channel delivering events addressed to the user across all of their connections. */
-export function userChannel(userId: number): string {
- return `user__${userId}`;
-}
-
-/** Channel delivering a chat room's events to its viewers. */
-export function chatRoomChannel(roomId: number): string {
- return `chat-room__${roomId}`;
-}
-
interface Subscriber {
queue: ServerEvent[];
wake: (() => void) | null;
diff --git a/app/features/events/events-types.ts b/app/features/events/events-types.ts
index 7db05d32e..cc66c277d 100644
--- a/app/features/events/events-types.ts
+++ b/app/features/events/events-types.ts
@@ -4,6 +4,16 @@ import type {
SoundOnlySystemMessageType,
} from "~/features/chat/chat-types";
+/** Channel delivering events addressed to the user across all of their connections. */
+export function userChannel(userId: number): string {
+ return `user__${userId}`;
+}
+
+/** Channel delivering a chat room's events to its viewers. */
+export function chatRoomChannel(roomId: number): string {
+ return `chat-room__${roomId}`;
+}
+
export type ServerEvent =
| { kind: "chatMessage"; roomId: number; message: ChatMessageWithAuthor }
| {
diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts
index ed0045692..b9ba64c63 100644
--- a/app/features/scrims/loaders/scrims.$id.server.ts
+++ b/app/features/scrims/loaders/scrims.$id.server.ts
@@ -1,10 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
-import { chatAccessible } from "~/features/chat/chat-utils";
-import * as EventBus from "~/features/events/core/EventBus.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
-import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfNullish } from "../../../utils/remix.server";
import {
type AuthenticatedUser,
@@ -55,16 +52,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
include: { friendCode: true },
})),
post,
- chatCode:
- (user.roles.includes("STAFF") || participantIds.includes(user.id)) &&
- post.chatRoomId !== null &&
- chatAccessible({
- isStaff: user.roles.includes("STAFF"),
- expiresAfterDays: 1,
- comparedTo: databaseTimestampToDate(Scrim.getStartTime(post)),
- })
- ? EventBus.chatRoomChannel(post.chatRoomId)
- : undefined,
+ chatRoomIds:
+ participantIds.includes(user.id) && post.chatRoomId !== null
+ ? [post.chatRoomId]
+ : [],
anyUserPrefersNoScreen,
mapByMap,
};
diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts
index bc3157743..83b84ddad 100644
--- a/app/features/sendouq-match/actions/q.match.$id.server.ts
+++ b/app/features/sendouq-match/actions/q.match.$id.server.ts
@@ -250,6 +250,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
}
+ // non-continuing members lose the group room
+ ChatSystemMessage.notifyRoomsChanged(
+ viewerGroup.members.map((member) => member.id),
+ );
+
// The continuing group re-enters the looking pool, so refresh
// every looking client.
ChatSystemMessage.send({
diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts
index e94bd382f..5cbac3572 100644
--- a/app/features/sendouq-match/loaders/q.match.$id.server.ts
+++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts
@@ -1,7 +1,5 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
-import { chatAccessible } from "~/features/chat/chat-utils";
-import * as EventBus from "~/features/events/core/EventBus.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
@@ -9,7 +7,6 @@ import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
-import { databaseTimestampToDate } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { qMatchPageParamsSchema } from "../q-match-schemas";
@@ -56,33 +53,19 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
reportedWeapons,
ingestedScoreboards,
isOffSeason: Seasons.current() === null,
- chatCode: (() => {
- if (!(isStaff || isParticipant)) return null;
+ chatRoomIds: (() => {
+ // observers (staff) get room access through the moderation view instead
+ if (!user || !isParticipant) return [];
- const accessible = chatAccessible({
- isStaff,
- expiresAfterDays: 1,
- comparedTo: databaseTimestampToDate(matchUnmapped.createdAt),
- });
- if (!accessible) return null;
+ const ownGroup = matchUnmapped.groupAlpha.members.some(
+ (member) => member.id === user.id,
+ )
+ ? match.groupAlpha
+ : match.groupBravo;
- if (!isParticipant) {
- return match.chatRoomId
- ? EventBus.chatRoomChannel(match.chatRoomId)
- : null;
- }
-
- const codes = [
- match.chatRoomId,
- match.groupAlpha.chatRoomId,
- match.groupBravo.chatRoomId,
- ]
- .filter((id): id is number => typeof id === "number")
- .map(EventBus.chatRoomChannel);
-
- if (codes.length === 0) return null;
- if (codes.length === 1) return codes[0];
- return codes;
+ return [match.chatRoomId, ownGroup.chatRoomId].filter(
+ (id): id is number => typeof id === "number",
+ );
})(),
};
};
diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx
index fdb8f267b..49f2be653 100644
--- a/app/features/sendouq-match/routes/q.match.$id.tsx
+++ b/app/features/sendouq-match/routes/q.match.$id.tsx
@@ -43,7 +43,7 @@ export default function SendouQMatchPage() {
const data = useLoaderData();
// the page's updates are broadcast to its chat room, subscribed to by the chat
- // provider via the loader's chatCode rather than by a topic of our own
+ // provider via the loader's chatRoomIds rather than by a topic of our own
useLiveRevalidation();
return (
diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts
index 1ccde0dd2..a268aa233 100644
--- a/app/features/sendouq/actions/q.looking.server.ts
+++ b/app/features/sendouq/actions/q.looking.server.ts
@@ -171,6 +171,13 @@ export const action: ActionFunction = async ({ request }) => {
});
}
+ // both old rooms died and a fresh merged room was created
+ ChatSystemMessage.notifyRoomsChanged(
+ [...ourGroup.members, ...theirGroup.members].map(
+ (member) => member.id,
+ ),
+ );
+
broadcastLookingUpdate();
break;
@@ -226,6 +233,10 @@ export const action: ActionFunction = async ({ request }) => {
});
}
+ ChatSystemMessage.notifyRoomsChanged(
+ currentGroup.members.map((member) => member.id),
+ );
+
broadcastLookingUpdate();
throw redirect(SENDOUQ_PAGE);
@@ -262,6 +273,10 @@ export const action: ActionFunction = async ({ request }) => {
});
}
+ ChatSystemMessage.notifyRoomsChanged(
+ currentGroup.members.map((member) => member.id),
+ );
+
broadcastLookingUpdate();
break;
diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts
index 991736e22..536743554 100644
--- a/app/features/sendouq/actions/q.preparing.server.ts
+++ b/app/features/sendouq/actions/q.preparing.server.ts
@@ -83,6 +83,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
}
+ ChatSystemMessage.notifyRoomsChanged(
+ updatedGroup
+ ? updatedGroup.members.map((member) => member.id)
+ : [data.id],
+ );
+
ChatSystemMessage.send({
room: sqGroupWebsocketRoom(ownGroup.id),
revalidateOnly: true,
diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts
index efa31f692..3928bc517 100644
--- a/app/features/sendouq/loaders/q.looking.server.ts
+++ b/app/features/sendouq/loaders/q.looking.server.ts
@@ -1,7 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
-import * as EventBus from "~/features/events/core/EventBus.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
@@ -70,9 +69,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
: [],
lastUpdated: Date.now(),
streamsCount: (await cachedStreams()).length,
- chatCode:
+ chatRoomIds:
ownGroup && ownGroup.members.length > 1 && ownGroup.chatRoomId !== null
- ? EventBus.chatRoomChannel(ownGroup.chatRoomId)
- : null,
+ ? [ownGroup.chatRoomId]
+ : [],
};
};
diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
index 93e2f0cf5..08c5234dc 100644
--- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
+++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
@@ -1,7 +1,6 @@
import cachified from "@epic-web/cachified";
import type { LoaderFunctionArgs } from "react-router";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
-import { chatAccessible } from "~/features/chat/chat-utils";
import * as EventBus from "~/features/events/core/EventBus.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
@@ -202,24 +201,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
}
- const hasPermsToSeeChat =
- tournament.isOrganizerOrStreamer(user) ||
- match.players.some((p) => p.id === user?.id);
-
const isSiteStaff = user?.roles.includes("STAFF") ?? false;
const isTournamentStaff = tournament.isOrganizer(user);
- const chatCodeExpired =
- tournament.ctx.isFinalized && !isSiteStaff && !isTournamentStaff
- ? true
- : !chatAccessible({
- expiresAfterDays: tournament.isLeague ? 30 : 7,
- comparedTo: tournament.ctx.startsAt,
- });
-
- const visibleChatCode =
- hasPermsToSeeChat && !chatCodeExpired && match.chatRoomId
- ? EventBus.chatRoomChannel(match.chatRoomId)
- : undefined;
const isParticipant = match.players.some((p) => p.id === user?.id);
const leagueRoundLocked = isLeagueRoundLocked(tournament, match.roundId);
@@ -251,7 +234,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
match: {
...match,
status,
- chatRoomId: hasPermsToSeeChat ? match.chatRoomId : undefined,
+ chatRoomId: undefined,
},
results,
reportedWeapons,
@@ -266,7 +249,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
matchIsOver,
endedEarly,
noScreen,
- chatCode: visibleChatCode,
+ // observers (TO/streamer/staff) get room access through the moderation view instead
+ chatRoomIds: isParticipant && match.chatRoomId ? [match.chatRoomId] : [],
canJoin,
// the views can't derive these themselves, the layout ships no bracket match data
bracketContext: {
diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts
index c5da444a0..7b54401d6 100644
--- a/app/features/user-page/UserRepository.server.ts
+++ b/app/features/user-page/UserRepository.server.ts
@@ -31,7 +31,7 @@ import {
} from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
import { bskyUrl, twitchUrl, youtubeUrl } from "~/utils/urls";
-import type { ChatUser } from "../chat/chat-types";
+import type { ChatMessageAuthor } from "../chat/chat-types";
import { sortBadgesByFavorites } from "./core/badge-sorting.server";
import { findWidgetById } from "./core/widgets/portfolio";
import { WIDGET_LOADERS } from "./core/widgets/portfolio-loaders.server";
@@ -539,7 +539,7 @@ export async function findChatUsersByUserIds(userIds: number[]) {
.where("User.id", "in", userIds)
.execute();
- const result: Record = {};
+ const result: Record = {};
for (const user of users) {
result[user.id] = user;
diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx
index 637439190..4e6b86be5 100644
--- a/app/form/SendouForm.tsx
+++ b/app/form/SendouForm.tsx
@@ -129,6 +129,16 @@ type BaseFormProps = {
* back to a read-only view.
*/
onSuccess?: () => void;
+ /**
+ * Hides the built-in submit button, for forms that render their own submit
+ * control inside `children` (e.g. the chat composer's send button).
+ */
+ hideSubmitButton?: boolean;
+ /**
+ * When false, navigating away with unsaved edits is not blocked. For forms
+ * whose value is ephemeral by nature, like a chat message draft.
+ */
+ guardUnsavedChanges?: boolean;
};
/**
@@ -208,6 +218,8 @@ function SendouFormInner({
secondarySubmit,
hideSubmitButtonWhen,
onSuccess,
+ hideSubmitButton = false,
+ guardUnsavedChanges = true,
}: SendouFormProps) {
const { t } = useTranslation(["forms"]);
const fetcher = useFetcher<{ fieldErrors?: Record }>();
@@ -283,7 +295,11 @@ function SendouFormInner({
const hasUnsavedChangesRef = React.useRef<() => boolean>(() => false);
hasUnsavedChangesRef.current = () =>
- mode === "submit" && !readOnly && store.dirty && fetcher.state === "idle";
+ guardUnsavedChanges &&
+ mode === "submit" &&
+ !readOnly &&
+ store.dirty &&
+ fetcher.state === "idle";
useUnsavedChangesChecker(hasUnsavedChangesRef);
const previousFetcherStateRef = React.useRef(fetcher.state);
@@ -340,7 +356,7 @@ function SendouFormInner({
<>
{title ? {title}
: null}
{resolvedChildren}
- {mode !== "submit" || readOnly ? null : (
+ {mode !== "submit" || readOnly || hideSubmitButton ? null : (
boolean) | undefined
diff --git a/locales/da/common.json b/locales/da/common.json
index b5ae99d44..01eedf31a 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/da/forms.json b/locales/da/forms.json
index 8cceae3ab..33e9c54f1 100644
--- a/locales/da/forms.json
+++ b/locales/da/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Våbenpulje",
+ "placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/de/common.json b/locales/de/common.json
index ebfc31b46..cacb40b58 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/de/forms.json b/locales/de/forms.json
index 03964118d..295ba92bb 100644
--- a/locales/de/forms.json
+++ b/locales/de/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Waffenpool",
+ "placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/en/common.json b/locales/en/common.json
index 29390654f..8cc9e75b0 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "New messages",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No active chats",
+ "chat.sidebar.inactive": "Inactive",
+ "chat.room.group": "Group ({{members}}/4)",
+ "chat.room.groupShort": "Group",
+ "chat.room.match": "Match #{{id}}",
+ "chat.room.matchShort": "Match",
+ "chat.room.scrim": "Scrim",
"fc.title": "Friend code",
"fc.whereToFind": "Find your friend code on your Nintendo Switch (2)",
"fc.onceSetStaffOnly": "Once set, only a member of staff can change it",
diff --git a/locales/en/forms.json b/locales/en/forms.json
index f42433bf3..5b02111af 100644
--- a/locales/en/forms.json
+++ b/locales/en/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "Name can't be only special characters",
"errors.customRoleRequired": "Enter a name for the custom role",
"labels.weaponPool": "Weapon pool",
+ "placeholders.chatMessage": "Press enter to send",
"placeholders.weaponPoolFull": "Pool full - remove a weapon to add more",
"placeholders.vodStartTimestamp": "10:22",
"labels.voiceChat": "Can voice chat",
diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json
index 32edf2dad..389829411 100644
--- a/locales/es-ES/common.json
+++ b/locales/es-ES/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "Nuevos mensajes",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No hay chats activos",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "Clave de amigo",
"fc.whereToFind": "Encuentra tu clave de amigo en tu Nintendo Switch (2)",
"fc.onceSetStaffOnly": "Una vez guardada, solo un miembro del staff puede modificarla.",
diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json
index 4bd06aa6a..48daa978f 100644
--- a/locales/es-ES/forms.json
+++ b/locales/es-ES/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales",
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
"labels.weaponPool": "Selección de armas",
+ "placeholders.chatMessage": "Presiona Enter para enviar",
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
"placeholders.vodStartTimestamp": "10:22",
"labels.voiceChat": "Puede usar chat de voz",
diff --git a/locales/es-US/common.json b/locales/es-US/common.json
index 9d03c4c24..9ccee95a1 100644
--- a/locales/es-US/common.json
+++ b/locales/es-US/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "Nuevos mensajes",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No hay chats activos",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "Clave de amigo",
"fc.whereToFind": "Encuentra tu clave de amigo en tu Nintendo Switch (2)",
"fc.onceSetStaffOnly": "Una vez guardada, solo un miembro del staff puede modificarla.",
diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json
index 633c5fdc3..66b67733f 100644
--- a/locales/es-US/forms.json
+++ b/locales/es-US/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales",
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
"labels.weaponPool": "Grupo de armas",
+ "placeholders.chatMessage": "Presionar 'enter' para enviar",
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
"placeholders.vodStartTimestamp": "10:22",
"labels.voiceChat": "Puede usar chat de voz",
diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json
index 2a7a8ad45..c091f6da6 100644
--- a/locales/fr-CA/common.json
+++ b/locales/fr-CA/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json
index 5905bfc18..fb4603e30 100644
--- a/locales/fr-CA/forms.json
+++ b/locales/fr-CA/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Armes jouées",
+ "placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json
index c2ef81f2c..03b24b5f7 100644
--- a/locales/fr-EU/common.json
+++ b/locales/fr-EU/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "Nouveau message",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "Code ami ",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json
index 13d9ea32e..d19e2f310 100644
--- a/locales/fr-EU/forms.json
+++ b/locales/fr-EU/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Armes jouées",
+ "placeholders.chatMessage": "Appuyer sur entrer pour envoyer",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/he/common.json b/locales/he/common.json
index c8d15cc38..a306d7d65 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/he/forms.json b/locales/he/forms.json
index 216bcd878..db862be3f 100644
--- a/locales/he/forms.json
+++ b/locales/he/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "מאגר נשקים",
+ "placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/it/common.json b/locales/it/common.json
index 4bf376419..becc59f31 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "Nuovi messaggi",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "Codice amico",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/it/forms.json b/locales/it/forms.json
index 9fd023eec..c72ace773 100644
--- a/locales/it/forms.json
+++ b/locales/it/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Pool armi",
+ "placeholders.chatMessage": "Premi Invio per inviare",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index d9bd59fe9..fb250cfda 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "新着メッセージ",
"chat.sidebar.title": "チャット",
"chat.sidebar.noActiveChats": "使用中のチャットはありません",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "フレンドコード",
"fc.whereToFind": "フレンドコードは Nintendo Switch から見ることができます",
"fc.onceSetStaffOnly": "フレンドコードは一度設定すると、変更できるのはスタッフのみです。",
diff --git a/locales/ja/forms.json b/locales/ja/forms.json
index a8f3be794..817990d61 100644
--- a/locales/ja/forms.json
+++ b/locales/ja/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "使用ブキ",
+ "placeholders.chatMessage": "送信するには enter を押してください",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 5209e10e2..cd07045ac 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/ko/forms.json b/locales/ko/forms.json
index 2c5e6a620..45080fe08 100644
--- a/locales/ko/forms.json
+++ b/locales/ko/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "",
+ "placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index b1164dbae..a9bce9122 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/nl/forms.json b/locales/nl/forms.json
index 822ca81c0..7212d7144 100644
--- a/locales/nl/forms.json
+++ b/locales/nl/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "",
+ "placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 979b5eaaa..cd47b02e1 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/pl/forms.json b/locales/pl/forms.json
index 7ac276cb1..058c8dec1 100644
--- a/locales/pl/forms.json
+++ b/locales/pl/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Pula broni",
+ "placeholders.chatMessage": "",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json
index 1e5c9e372..370ec56bc 100644
--- a/locales/pt-BR/common.json
+++ b/locales/pt-BR/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "Código de amigo",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json
index 98d6edf90..07e24e069 100644
--- a/locales/pt-BR/forms.json
+++ b/locales/pt-BR/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Seleção de armas",
+ "placeholders.chatMessage": "Aperte enter para enviar",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index d5c909534..af1265ba4 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "Новые сообщения",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "Код друга",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",
diff --git a/locales/ru/forms.json b/locales/ru/forms.json
index a059b5e5c..974353c3e 100644
--- a/locales/ru/forms.json
+++ b/locales/ru/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "",
"errors.customRoleRequired": "",
"labels.weaponPool": "Используемое оружие",
+ "placeholders.chatMessage": "Нажмите enter, чтобы отправить",
"placeholders.weaponPoolFull": "",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 68fcddaa7..90d7e1ed8 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -374,6 +374,12 @@
"chat.newMessages": "新消息",
"chat.sidebar.title": "聊天",
"chat.sidebar.noActiveChats": "暂无活跃聊天",
+ "chat.sidebar.inactive": "",
+ "chat.room.group": "",
+ "chat.room.groupShort": "",
+ "chat.room.match": "",
+ "chat.room.matchShort": "",
+ "chat.room.scrim": "",
"fc.title": "好友编号",
"fc.whereToFind": "在您的 Nintendo Switch (2) 上找到好友编号",
"fc.onceSetStaffOnly": "设置完成后,只有工作人员能修改",
diff --git a/locales/zh/forms.json b/locales/zh/forms.json
index e2561dfd0..942e51103 100644
--- a/locales/zh/forms.json
+++ b/locales/zh/forms.json
@@ -72,6 +72,7 @@
"errors.noOnlySpecialCharacters": "名称不能仅由特殊字符组成",
"errors.customRoleRequired": "请输入自定义职责的名称",
"labels.weaponPool": "武器池",
+ "placeholders.chatMessage": "按回车键发送",
"placeholders.weaponPoolFull": "武器池已满。请移除一个武器以添加新武器",
"placeholders.vodStartTimestamp": "",
"labels.voiceChat": "可以进行语音聊天吗?",
diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts
index c136b9d70..02078aefb 100644
--- a/scripts/benchmark-db/cases.ts
+++ b/scripts/benchmark-db/cases.ts
@@ -242,10 +242,10 @@ export function buildCases(fx: Fixtures): {
ChatRepository.findMessageById(messageId),
);
add(
- "ChatRepository.findUnreadCountsByRoomIds",
+ "ChatRepository.findMessageStatsByRoomIds",
both(fx.heavyUser, fx.heavyChatRoomId),
([user, roomId]) =>
- ChatRepository.findUnreadCountsByRoomIds(user.id, [roomId]),
+ ChatRepository.findMessageStatsByRoomIds(user.id, [roomId]),
);
// ChatRoomResolver