Voice chat beta (#3451)

This commit is contained in:
hfcRed
2026-10-11 10:51:43 -04:00
committed by GitHub
parent d0f20f30f8
commit 643b8744bf
98 changed files with 6005 additions and 19 deletions

View File

@@ -46,6 +46,11 @@ SQL_LOG=none
VITE_SHOW_LUTI_NAV_ITEM=false
// Voice chat https://dashboard.daily.co (voice is unavailable when unset).
// DAILY_DOMAIN is the subdomain of the account (<domain>.daily.co), the webhook secret is the hmac returned when creating the webhook
DAILY_API_KEY=
DAILY_DOMAIN=
DAILY_WEBHOOK_SECRET=
// Push notification. Generate values here https://vapidkeys.com/
VITE_VAPID_PUBLIC_KEY=

View File

@@ -232,7 +232,7 @@
}
}
@container chat-split (height < 800px) {
@container chat-split (height < 850px) {
.splitTabs {
display: grid;
grid-template-rows: auto minmax(0, 1fr);

View File

@@ -23,6 +23,8 @@ import {
import type { ChatRoomListItem } from "~/features/chat/chat-types";
import { Chat } from "~/features/chat/components/Chat";
import * as LogLayout from "~/features/chat/core/LogLayout";
import { VoiceBar, VoiceCallStrip } from "~/features/voice/components/VoiceBar";
import { useVoiceSnapshot } from "~/features/voice/voice-hooks";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl } from "~/utils/urls";
@@ -223,6 +225,7 @@ function RoomList({
return (
<div className={styles.sidebar}>
<SidebarHeader onClose={onClose} />
<ActiveVoiceCallStrip />
<div className={styles.roomList}>
{!hasAnyRoom ? (
<div className={styles.emptyState}>
@@ -441,6 +444,8 @@ function SingleChatView({
</button>
) : null}
</div>
<ActiveVoiceCallStrip />
{room ? <VoiceBar key={room.id} room={room} /> : null}
<div className={styles.chatContainer}>
{/* keyed so switching rooms starts the scroller and its unseen state over */}
{room ? <RoomChat key={room.id} room={room} /> : null}
@@ -493,6 +498,12 @@ function CombinedChatView({
};
}, [backgroundRoomIdsKey, setHiddenRoomIds]);
const { call } = useVoiceSnapshot();
const backgroundCallRoom =
call && call.roomId !== selectedRoomId
? rooms.find((room) => room.id === call.roomId)
: undefined;
const primary = rooms[0];
const display = roomDisplay(primary);
const headerContent = (
@@ -538,6 +549,7 @@ function CombinedChatView({
</button>
) : null}
</div>
<ActiveVoiceCallStrip />
<div ref={splitViewRef} className={styles.splitView}>
<SendouTabs
selectedKey={roomTabId(selectedRoomId)}
@@ -566,6 +578,9 @@ function CombinedChatView({
</SendouTab>
))}
</SendouTabList>
{backgroundCallRoom ? (
<VoiceBar key={backgroundCallRoom.id} room={backgroundCallRoom} />
) : null}
</div>
{rooms.map((room) => (
<SplitPanel
@@ -607,6 +622,7 @@ function SplitPanel({
onFocus={onFocus}
>
<div className={styles.splitPanelHeader}>{roomShortLabel(room, t)}</div>
<VoiceBar room={room} />
<div className={styles.chatContainer}>
<RoomChat room={room} />
</div>
@@ -639,6 +655,27 @@ function RoomChat({ room }: { room: ChatRoomListItem }) {
);
}
function ActiveVoiceCallStrip() {
const { call } = useVoiceSnapshot();
const chatContext = useChatContext()!;
const roomDisplay = useRoomDisplay();
if (!call || chatContext.activeRoomIds.includes(call.roomId)) return null;
const room = chatContext.roomForId(call.roomId);
if (!room) return null;
return (
<VoiceCallStrip
title={roomDisplay(room).title}
onOpen={() => {
chatContext.ensureMessagesLoaded(room.id);
chatContext.setActiveRoomIds([room.id]);
}}
/>
);
}
function byRecency(a: ChatRoomListItem, b: ChatRoomListItem) {
const recency = (b.latestMessageAt ?? 0) - (a.latestMessageAt ?? 0);
if (recency !== 0) return recency;

View File

@@ -42,10 +42,15 @@ const schema = v.pipe(
// webPush.server.ts checks all three are set together
VAPID_PRIVATE_KEY: v.optional(v.string()),
VAPID_EMAIL: v.optional(v.string()),
DAILY_API_KEY: v.optional(v.string()),
DAILY_DOMAIN: v.optional(v.string()),
DAILY_WEBHOOK_SECRET: v.optional(v.string()),
}),
superRefine((val, ctx) => {
requireTogether(ctx, val, "TWITCH_CLIENT_ID", "TWITCH_CLIENT_SECRET");
requireTogether(ctx, val, "VAPID_EMAIL", "VAPID_PRIVATE_KEY");
requireTogether(ctx, val, "DAILY_API_KEY", "DAILY_DOMAIN");
}),
);
@@ -101,6 +106,13 @@ export const ServerConfig = {
privateKey: values.VAPID_PRIVATE_KEY,
email: values.VAPID_EMAIL,
},
/** Voice chat. Optional, voice is unavailable when unset. */
daily: {
apiKey: values.DAILY_API_KEY,
domain: values.DAILY_DOMAIN,
webhookSecret: values.DAILY_WEBHOOK_SECRET,
},
};
/** Adds a validation issue unless `a` and `b` are both set or both unset. */

View File

@@ -0,0 +1,47 @@
import * as VoiceRepository from "~/features/voice/VoiceRepository.server";
import { actAs } from "../core/actAs";
import { backdate } from "../core/backdate";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Parameters<typeof VoiceRepository.insertOwnSession>[0] & {
/** Who asked to join. */
userId: number;
};
type Options = {
dailySessionId?: string;
connectedAt?: Date;
createdAt?: Date;
};
export const { create } = defineFactory({
defaults: () => ({
roomId: null,
roomType: "SQ_MATCH" as const,
platform: "DESKTOP" as const,
eligibleMemberCount: 8,
vcPreference: "YES" as const,
}),
insert: async ({ userId, ...args }: InsertArgs) => {
const id = await actAs(userId, () =>
VoiceRepository.insertOwnSession(args),
);
return { id, userId };
},
applyOptions: async (
session,
{ dailySessionId, connectedAt, createdAt }: Options,
) => {
if (dailySessionId) {
await actAs(session.userId, () =>
VoiceRepository.markOwnSessionConnected({
id: session.id,
dailySessionId,
}),
);
}
await backdate("VoiceSession", session.id, { connectedAt, createdAt });
},
});

View File

@@ -59,6 +59,7 @@ export interface UserPreferences {
/** Hides recent tournament results and scores until revealed */
spoilerFreeMode?: boolean;
weaponReportDefaultOpen?: boolean;
voicePushToTalk?: boolean;
/** Start of the week the schedule sidebar nudge was last dismissed for, so it stays gone until the horizon rolls over. */
scheduleNudgeDismissedWeekStartsAt?: number;
/** Who may see the user's schedule. Missing = everyone. Once set it is an allow-list, so a team joined later stays hidden until added. */

View File

@@ -66,6 +66,13 @@ import type { StoredWidget } from "~/features/user-page/core/widgets/types";
import type { BuildSort } from "~/features/user-page/user-page-constants";
import type { UserReportCategory } from "~/features/user-report/user-report-constants";
import type { videoMatchTypes } from "~/features/vods/vods-constants";
import type {
VoiceClientErrorKind,
VoiceInputMode,
VoiceLeaveReason,
VoiceNetworkQualityState,
VoicePlatform,
} from "~/features/voice/voice-constants";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import type {
Ability,
@@ -1020,7 +1027,7 @@ export interface User {
patronExpiresAt: number | null;
twitch: string | null;
bsky: string | null;
vc: Generated<"YES" | "NO" | "LISTEN_ONLY">;
vc: Generated<UserVoiceChat>;
youtubeId: string | null;
youtubeName: string | null;
mapModePreferences: JSONColumnTypeNullable<UserMapModePreferences>;
@@ -1206,6 +1213,60 @@ export interface VideoMatchPlayer {
weaponSplId: number;
}
export type UserVoiceChat = "YES" | "NO" | "LISTEN_ONLY";
export interface VoiceSession {
id: GeneratedAlways<number>;
/** `null` once the chat room has been deleted. The session stays for the statistics. */
roomId: number | null;
roomType: ChatRoomType;
userId: number;
platform: VoicePlatform;
/** Members of the room who could have joined voice when this session started based on voice chat settings. We need these to check for adoption. */
eligibleMemberCount: number;
/** The joiner's own voice chat setting ({@link User.vc}) when they joined. */
vcPreference: UserVoiceChat;
dailySessionId: string | null;
createdAt: Generated<number>;
connectedAt: number | null;
leftAt: number | null;
/** Billed seconds in the call as reported by Daily, `null` until its webhook connects. */
durationSeconds: number | null;
leaveReason: VoiceLeaveReason | null;
networkQualityState: VoiceNetworkQualityState | null;
lowQualitySeconds: Generated<number>;
talkSeconds: Generated<number>;
inputMode: VoiceInputMode | null;
}
export interface VoiceClientError {
id: GeneratedAlways<number>;
voiceSessionId: number | null;
userId: number;
kind: VoiceClientErrorKind;
detail: string | null;
platform: VoicePlatform;
createdAt: Generated<number>;
}
export interface VoiceFeedback {
id: GeneratedAlways<number>;
voiceSessionId: number;
userId: number;
/** 1-5 */
rating: number;
comment: string | null;
createdAt: Generated<number>;
}
export interface VoiceSettings {
id: GeneratedAlways<number>;
/** Manual kill switch. */
isDisabled: Generated<DBBoolean>;
updatedAt: number | null;
updatedByUserId: number | null;
}
export interface XRankPlacement {
badges: string;
bannerSplId: number;
@@ -1526,6 +1587,10 @@ export interface DB {
Video: Video;
VideoMatch: VideoMatch;
VideoMatchPlayer: VideoMatchPlayer;
VoiceClientError: VoiceClientError;
VoiceFeedback: VoiceFeedback;
VoiceSession: VoiceSession;
VoiceSettings: VoiceSettings;
XRankPlacement: XRankPlacement;
ScrimPost: ScrimPost;
ScrimPostUser: ScrimPostUser;

View File

@@ -30,4 +30,5 @@ export type ServerEvent =
| { kind: "revalidate"; scope?: RevalidateScope; authorUserId?: number }
| { kind: "notificationsChanged" }
| { kind: "roomsChanged" }
| { kind: "statusChanged" };
| { kind: "statusChanged" }
| { kind: "voicePresence"; roomId: number; userIds: number[] };

View File

@@ -55,6 +55,12 @@ export const action = defineAction(
});
break;
}
case "UPDATE_VOICE_PUSH_TO_TALK": {
await UserRepository.updateOwnPreferences({
voicePushToTalk: body.newValue,
});
break;
}
case "UPDATE_CLOCK_FORMAT": {
await UserRepository.updateOwnPreferences({
clockFormat: body.newValue,

View File

@@ -18,6 +18,7 @@ import {
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
voicePushToTalkSchema,
} from "../settings-schemas";
export function PreferencesTab() {
@@ -63,6 +64,17 @@ export function PreferencesTab() {
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={voicePushToTalkSchema}
defaultValues={{
newValue: user.preferences.voicePushToTalk ?? false,
}}
mode="autoSubmit"
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</div>
);

View File

@@ -2,6 +2,10 @@ import { Volume2 } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import * as Sounds from "~/features/chat/core/Sounds";
import {
VOICE_JOIN_SOUND,
VOICE_LEAVE_SOUND,
} from "~/features/voice/voice-constants";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./SoundsTab.module.css";
@@ -27,6 +31,8 @@ function SoundCheckboxes() {
code: "tournament_match",
name: t("settings:sounds.tournamentMatchStarted"),
},
{ code: VOICE_JOIN_SOUND, name: t("settings:sounds.voiceJoin") },
{ code: VOICE_LEAVE_SOUND, name: t("settings:sounds.voiceLeave") },
];
const [soundValues, setSoundValues] = React.useState(

View File

@@ -44,6 +44,14 @@ export const spoilerFreeModeSchema = v.object({
}),
});
export const voicePushToTalkSchema = v.object({
_action: stringConstant("UPDATE_VOICE_PUSH_TO_TALK"),
newValue: toggle({
label: "labels.voicePushToTalk",
bottomText: "bottomTexts.voicePushToTalk",
}),
});
export const weaponReportDefaultOpenSchema = v.object({
_action: stringConstant("UPDATE_WEAPON_REPORT_DEFAULT_OPEN"),
newValue: v.boolean(),
@@ -54,6 +62,7 @@ export const settingsEditSchema = v.union([
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
voicePushToTalkSchema,
clockFormatSchema,
weaponReportDefaultOpenSchema,
]);

View File

@@ -0,0 +1,309 @@
import { subDays, subMinutes } from "date-fns";
import { beforeEach, describe, expect, test } from "vitest";
import * as ChatRoomFactory from "~/db/seed/factories/ChatRoomFactory";
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as VoiceSessionFactory from "~/db/seed/factories/VoiceSessionFactory";
import { db } from "~/db/sql";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { withUserId } from "~/utils/Test";
import * as VoiceRepository from "./VoiceRepository.server";
const users = UserFactory.pool();
const playerId = () => users.id(1);
const otherPlayerId = () => users.id(2);
beforeEach(async () => {
await users.create(8);
});
const sessionRow = (id: number) =>
db
.selectFrom("VoiceSession")
.selectAll()
.where("id", "=", id)
.executeTakeFirstOrThrow();
describe("VoiceRepository.sumParticipantMinutesSince", () => {
const monthStart = () => dateToDatabaseTimestamp(subDays(new Date(), 7));
test("counts Daily's billed duration over the reported times", async () => {
await VoiceSessionFactory.create(
{ userId: playerId() },
{ dailySessionId: "daily-1", connectedAt: subMinutes(new Date(), 30) },
);
await VoiceRepository.updateFromLeftWebhook({
dailySessionId: "daily-1",
durationSeconds: 600,
networkQualityState: "good",
leftAt: databaseTimestampNow(),
});
expect(await VoiceRepository.sumParticipantMinutesSince(monthStart())).toBe(
10,
);
});
test("counts an ongoing session up to now", async () => {
await VoiceSessionFactory.create(
{ userId: playerId() },
{ dailySessionId: "daily-1", connectedAt: subMinutes(new Date(), 20) },
);
expect(
await VoiceRepository.sumParticipantMinutesSince(monthStart()),
).toBeCloseTo(20, 0);
});
test("ignores sessions that never connected and sessions before the timestamp", async () => {
await VoiceSessionFactory.create({ userId: playerId() });
await VoiceSessionFactory.create(
{ userId: otherPlayerId() },
{
dailySessionId: "daily-old",
createdAt: subDays(new Date(), 10),
connectedAt: subDays(new Date(), 10),
},
);
expect(await VoiceRepository.sumParticipantMinutesSince(monthStart())).toBe(
0,
);
});
});
describe("VoiceRepository.updateFromLeftWebhook", () => {
test("keeps the leave the client reported while taking Daily's duration", async () => {
const session = await VoiceSessionFactory.create(
{ userId: playerId() },
{ dailySessionId: "daily-1" },
);
await withUserId(playerId(), () =>
VoiceRepository.markOwnSessionLeft({
id: session.id,
leaveReason: "LEFT",
}),
);
const { leftAt } = await sessionRow(session.id);
await VoiceRepository.updateFromLeftWebhook({
dailySessionId: "daily-1",
durationSeconds: 90,
networkQualityState: "warning",
leftAt: databaseTimestampNow() + 60,
});
expect(await sessionRow(session.id)).toMatchObject({
leftAt,
leaveReason: "LEFT",
durationSeconds: 90,
networkQualityState: "warning",
});
});
test("ends a session the client never reported leaving as ejected", async () => {
const session = await VoiceSessionFactory.create(
{ userId: playerId() },
{ dailySessionId: "daily-1" },
);
await VoiceRepository.updateFromLeftWebhook({
dailySessionId: "daily-1",
durationSeconds: 90,
networkQualityState: "good",
leftAt: 1_800_000_000,
});
expect(await sessionRow(session.id)).toMatchObject({
leftAt: 1_800_000_000,
leaveReason: "EJECTED",
});
});
});
describe("VoiceRepository.insertOwnSession", () => {
test("ends the user's other open sessions so they stop counting toward the budget", async () => {
const previous = await VoiceSessionFactory.create(
{ userId: playerId() },
{ dailySessionId: "daily-1", connectedAt: subMinutes(new Date(), 30) },
);
const othersSession = await VoiceSessionFactory.create(
{ userId: otherPlayerId() },
{ dailySessionId: "daily-2" },
);
const id = await withUserId(playerId(), () =>
VoiceRepository.insertOwnSession({
roomId: null,
roomType: "SQ_MATCH",
platform: "DESKTOP",
eligibleMemberCount: 8,
vcPreference: "YES",
}),
);
expect((await sessionRow(previous.id)).leaveReason).toBe("REPLACED");
expect((await sessionRow(othersSession.id)).leftAt).toBeNull();
expect((await sessionRow(id)).leftAt).toBeNull();
});
});
describe("VoiceRepository.markOwnSessionLeft", () => {
test("does not overwrite a kick", async () => {
const room = await ChatRoomFactory.create();
const session = await VoiceSessionFactory.create(
{ userId: playerId(), roomId: room.id },
{ dailySessionId: "daily-1" },
);
await VoiceRepository.markSessionsKicked({
roomId: room.id,
userId: playerId(),
});
await withUserId(playerId(), () =>
VoiceRepository.markOwnSessionLeft({
id: session.id,
leaveReason: "CONNECTION_LOST",
}),
);
expect((await sessionRow(session.id)).leaveReason).toBe("KICKED");
});
test("only ends the acting user's own session", async () => {
const session = await VoiceSessionFactory.create({ userId: playerId() });
await withUserId(otherPlayerId(), () =>
VoiceRepository.markOwnSessionLeft({
id: session.id,
leaveReason: "LEFT",
}),
);
expect((await sessionRow(session.id)).leftAt).toBeNull();
});
});
describe("VoiceRepository.hasBeenKicked", () => {
test("holds only for the kicked user in the room they were kicked from", async () => {
const room = await ChatRoomFactory.create();
const otherRoom = await ChatRoomFactory.create();
await VoiceSessionFactory.create(
{ userId: playerId(), roomId: room.id },
{ dailySessionId: "daily-1" },
);
await VoiceSessionFactory.create(
{ userId: otherPlayerId(), roomId: room.id },
{ dailySessionId: "daily-2" },
);
await VoiceRepository.markSessionsKicked({
roomId: room.id,
userId: playerId(),
});
expect(
await VoiceRepository.hasBeenKicked({
roomId: room.id,
userId: playerId(),
}),
).toBe(true);
expect(
await VoiceRepository.hasBeenKicked({
roomId: room.id,
userId: otherPlayerId(),
}),
).toBe(false);
expect(
await VoiceRepository.hasBeenKicked({
roomId: otherRoom.id,
userId: playerId(),
}),
).toBe(false);
});
});
describe("VoiceRepository.findPlayerAdoptionSince", () => {
test("splits the players of new matches by voice chat setting and counts who connected", async () => {
const match = await createMatchWithVoiceChatSettings();
await connect(match.chatRoomId, users.id(1));
await connect(match.chatRoomId, users.id(2));
await VoiceSessionFactory.create({
userId: users.id(5),
roomId: match.chatRoomId,
});
const rows = await VoiceRepository.findPlayerAdoptionSince(weekAgo());
expect(
rows
.filter((row) => row.roomType === "SQ_MATCH")
.map(({ vc, playerCount, connectedCount }) => ({
vc,
playerCount,
connectedCount,
})),
).toEqual(
expect.arrayContaining([
{ vc: "YES", playerCount: 6, connectedCount: 1 },
{ vc: "LISTEN_ONLY", playerCount: 1, connectedCount: 1 },
{ vc: "NO", playerCount: 1, connectedCount: 0 },
]),
);
});
});
describe("VoiceRepository.findDailyAdoptionSince", () => {
test("counts rooms where two or more people connected apart from rooms with voice", async () => {
const conversation = await createMatchWithVoiceChatSettings({
isConcluded: true,
});
await connect(conversation.chatRoomId, users.id(1));
await connect(conversation.chatRoomId, users.id(2));
const alone = await createMatchWithVoiceChatSettings();
await connect(alone.chatRoomId, users.id(1));
const rows = await VoiceRepository.findDailyAdoptionSince(weekAgo());
const matchRow = rows.find((row) => row.type === "SQ_MATCH");
expect(matchRow).toMatchObject({
roomCount: 2,
voiceRoomCount: 2,
conversationRoomCount: 1,
});
});
});
const weekAgo = () => dateToDatabaseTimestamp(subDays(new Date(), 7));
async function createMatchWithVoiceChatSettings(
options: { isConcluded?: boolean } = {},
) {
for (const userId of users.ids()) {
await UserFactory.grant(userId, {
matchProfile: {
vc:
userId === users.id(2)
? "LISTEN_ONLY"
: userId === users.id(3)
? "NO"
: "YES",
},
});
}
const match = await SQMatchFactory.create(
{
alphaUserIds: users.ids(4),
bravoUserIds: users.ids().slice(4),
},
options,
);
return { chatRoomId: match.chatRoomId };
}
function connect(roomId: number | null, userId: number) {
return VoiceSessionFactory.create(
{ userId, roomId },
{ dailySessionId: crypto.randomUUID() },
);
}

View File

@@ -0,0 +1,527 @@
import { sql } from "kysely";
import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import { databaseTimestampNow } from "~/utils/dates";
import { commonUserJsonObject } from "~/utils/kysely.server";
import { toDBBoolean } from "~/utils/sql";
import {
VOICE_ROOM_TYPES,
VOICE_TOKEN_LIFETIME_MINUTES,
type VoiceLeaveReason,
} from "./voice-constants";
const MAX_SESSION_SECONDS = VOICE_TOKEN_LIFETIME_MINUTES * 60;
const LATEST_ERRORS_LIMIT = 50;
const LATEST_FEEDBACK_LIMIT = 30;
export async function findSettings() {
const row = await db
.selectFrom("VoiceSettings")
.select(["VoiceSettings.isDisabled", "VoiceSettings.updatedAt"])
.orderBy("VoiceSettings.id", "asc")
.executeTakeFirst();
return {
isDisabled: Boolean(row?.isDisabled),
updatedAt: row?.updatedAt ?? null,
};
}
export async function sumParticipantMinutesSince(since: number) {
const row = await db
.selectFrom("VoiceSession")
.select(
sql<number>`coalesce(sum(coalesce(
"VoiceSession"."durationSeconds",
min(coalesce("VoiceSession"."leftAt", ${databaseTimestampNow()}) - "VoiceSession"."connectedAt", ${MAX_SESSION_SECONDS})
)), 0) / 60.0`.as("minutes"),
)
.where("VoiceSession.connectedAt", "is not", null)
.where("VoiceSession.createdAt", ">=", since)
.executeTakeFirstOrThrow();
return row.minutes;
}
export async function hasFeedbackSince({
userId,
since,
}: {
userId: number;
since: number;
}) {
const row = await db
.selectFrom("VoiceFeedback")
.select("VoiceFeedback.id")
.where("VoiceFeedback.userId", "=", userId)
.where("VoiceFeedback.createdAt", ">=", since)
.executeTakeFirst();
return Boolean(row);
}
export async function hasBeenKicked({
roomId,
userId,
}: {
roomId: number;
userId: number;
}) {
const row = await db
.selectFrom("VoiceSession")
.select("VoiceSession.id")
.where("VoiceSession.roomId", "=", roomId)
.where("VoiceSession.userId", "=", userId)
.where("VoiceSession.leaveReason", "=", "KICKED")
.executeTakeFirst();
return Boolean(row);
}
export function findOwnSessionById(id: number) {
return db
.selectFrom("VoiceSession")
.select([
"VoiceSession.id",
"VoiceSession.roomId",
"VoiceSession.connectedAt",
"VoiceSession.leftAt",
])
.where("VoiceSession.id", "=", id)
.where("VoiceSession.userId", "=", actorId())
.executeTakeFirst();
}
export function findDailyAdoptionSince(since: number) {
const day = sql<string>`date("ChatRoom"."createdAt", 'unixepoch')`;
return db
.selectFrom("ChatRoom")
.select((eb) => [
day.as("day"),
"ChatRoom.type",
eb.fn.countAll<number>().as("roomCount"),
eb.fn
.count<number>("ChatRoom.id")
.filterWhere(
eb.exists(
eb
.selectFrom("VoiceSession")
.select("VoiceSession.id")
.whereRef("VoiceSession.roomId", "=", "ChatRoom.id")
.where("VoiceSession.connectedAt", "is not", null),
),
)
.as("voiceRoomCount"),
eb.fn
.count<number>("ChatRoom.id")
.filterWhere(
eb
.selectFrom("VoiceSession")
.select((sub) =>
sub.fn
.count<number>("VoiceSession.userId")
.distinct()
.as("connectedUserCount"),
)
.whereRef("VoiceSession.roomId", "=", "ChatRoom.id")
.where("VoiceSession.connectedAt", "is not", null),
">=",
2,
)
.as("conversationRoomCount"),
])
.where("ChatRoom.type", "in", [...VOICE_ROOM_TYPES])
.where("ChatRoom.createdAt", ">=", since)
.groupBy([day, "ChatRoom.type"])
.orderBy("day", "asc")
.execute();
}
export function findPlayerAdoptionSince(since: number) {
const matchMembers = db
.selectFrom("ChatRoom")
.innerJoin("GroupMatch", "GroupMatch.chatRoomId", "ChatRoom.id")
.innerJoin("GroupMember", (join) =>
join.on((eb) =>
eb.or([
eb("GroupMember.groupId", "=", eb.ref("GroupMatch.alphaGroupId")),
eb("GroupMember.groupId", "=", eb.ref("GroupMatch.bravoGroupId")),
]),
),
)
.select([
"ChatRoom.id as roomId",
"ChatRoom.type as roomType",
"ChatRoom.createdAt as roomCreatedAt",
"GroupMember.userId",
])
.where("ChatRoom.type", "=", "SQ_MATCH")
.where("ChatRoom.createdAt", ">=", since);
const groupMembers = db
.selectFrom("ChatRoom")
.innerJoin("Group", "Group.chatRoomId", "ChatRoom.id")
.innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
.select([
"ChatRoom.id as roomId",
"ChatRoom.type as roomType",
"ChatRoom.createdAt as roomCreatedAt",
"GroupMember.userId",
])
.where("ChatRoom.type", "=", "SQ_GROUP")
.where("ChatRoom.createdAt", ">=", since);
const day = sql<string>`date("RoomMember"."roomCreatedAt", 'unixepoch')`;
return db
.selectFrom(matchMembers.unionAll(groupMembers).as("RoomMember"))
.innerJoin("User", "User.id", "RoomMember.userId")
.select((eb) => [
day.as("day"),
"RoomMember.roomType",
"User.vc",
eb.fn.countAll<number>().as("playerCount"),
eb.fn
.countAll<number>()
.filterWhere(
eb.exists(
eb
.selectFrom("VoiceSession")
.select("VoiceSession.id")
.whereRef("VoiceSession.roomId", "=", "RoomMember.roomId")
.whereRef("VoiceSession.userId", "=", "RoomMember.userId")
.where("VoiceSession.connectedAt", "is not", null),
),
)
.as("connectedCount"),
])
.groupBy([day, "RoomMember.roomType", "User.vc"])
.orderBy("day", "asc")
.execute();
}
export function findVoiceChatPreferenceStatsSince(since: number) {
return db
.selectFrom("VoiceSession")
.select((eb) => [
"VoiceSession.vcPreference",
eb.fn.countAll<number>().as("joinCount"),
eb.fn
.count<number>("VoiceSession.id")
.filterWhere("VoiceSession.connectedAt", "is not", null)
.as("connectedCount"),
eb.fn
.count<number>("VoiceSession.userId")
.distinct()
.as("uniqueUserCount"),
eb.fn
.count<number>("VoiceSession.id")
.filterWhere("VoiceSession.talkSeconds", ">", 0)
.as("talkedCount"),
eb.fn.sum<number | null>("VoiceSession.talkSeconds").as("talkSeconds"),
])
.where("VoiceSession.createdAt", ">=", since)
.groupBy("VoiceSession.vcPreference")
.execute();
}
export function findVoiceChatPreferences(userIds: number[]) {
if (userIds.length === 0) return Promise.resolve([]);
return db
.selectFrom("User")
.select(["User.id", "User.vc"])
.where("User.id", "in", userIds)
.execute();
}
export function findPlatformStatsSince(since: number) {
const connectedSeconds = sql<number>`coalesce("VoiceSession"."leftAt", ${databaseTimestampNow()}) - "VoiceSession"."connectedAt"`;
return db
.selectFrom("VoiceSession")
.select((eb) => [
"VoiceSession.platform",
eb.fn.countAll<number>().as("joinCount"),
eb.fn
.count<number>("VoiceSession.id")
.filterWhere("VoiceSession.connectedAt", "is not", null)
.as("connectedCount"),
eb.fn
.count<number>("VoiceSession.id")
.filterWhere("VoiceSession.connectedAt", "is not", null)
.filterWhere(connectedSeconds, ">", 60)
.as("stayedCount"),
eb.fn
.count<number>("VoiceSession.userId")
.distinct()
.as("uniqueUserCount"),
sql<
number | null
>`avg(case when "VoiceSession"."connectedAt" is not null then min(${connectedSeconds}, ${MAX_SESSION_SECONDS}) end)`.as(
"averageSeconds",
),
eb.fn
.sum<number | null>("VoiceSession.lowQualitySeconds")
.as("lowQualitySeconds"),
eb.fn.sum<number | null>("VoiceSession.talkSeconds").as("talkSeconds"),
eb.fn
.count<number>("VoiceSession.id")
.filterWhere("VoiceSession.networkQualityState", "=", "bad")
.as("badNetworkCount"),
eb.fn
.count<number>("VoiceSession.id")
.filterWhere("VoiceSession.inputMode", "=", "PUSH_TO_TALK")
.as("pushToTalkCount"),
])
.where("VoiceSession.createdAt", ">=", since)
.groupBy("VoiceSession.platform")
.execute();
}
export function countLeaveReasonsSince(since: number) {
return db
.selectFrom("VoiceSession")
.select((eb) => [
"VoiceSession.platform",
"VoiceSession.leaveReason",
eb.fn.countAll<number>().as("count"),
])
.where("VoiceSession.createdAt", ">=", since)
.where("VoiceSession.leaveReason", "is not", null)
.groupBy(["VoiceSession.platform", "VoiceSession.leaveReason"])
.execute();
}
export function countClientErrorsSince(since: number) {
return db
.selectFrom("VoiceClientError")
.select((eb) => [
"VoiceClientError.kind",
"VoiceClientError.platform",
eb.fn.countAll<number>().as("count"),
])
.where("VoiceClientError.createdAt", ">=", since)
.groupBy(["VoiceClientError.kind", "VoiceClientError.platform"])
.execute();
}
export function findLatestClientErrors() {
return db
.selectFrom("VoiceClientError")
.innerJoin("User", "User.id", "VoiceClientError.userId")
.select((eb) => [
"VoiceClientError.id",
"VoiceClientError.kind",
"VoiceClientError.detail",
"VoiceClientError.platform",
"VoiceClientError.createdAt",
commonUserJsonObject(eb).as("user"),
])
.orderBy("VoiceClientError.id", "desc")
.limit(LATEST_ERRORS_LIMIT)
.execute();
}
export function countRatingsSince(since: number) {
return db
.selectFrom("VoiceFeedback")
.select((eb) => [
"VoiceFeedback.rating",
eb.fn.countAll<number>().as("count"),
])
.where("VoiceFeedback.createdAt", ">=", since)
.groupBy("VoiceFeedback.rating")
.orderBy("VoiceFeedback.rating", "asc")
.execute();
}
export function findLatestFeedbackComments() {
return db
.selectFrom("VoiceFeedback")
.innerJoin("User", "User.id", "VoiceFeedback.userId")
.innerJoin(
"VoiceSession",
"VoiceSession.id",
"VoiceFeedback.voiceSessionId",
)
.select((eb) => [
"VoiceFeedback.id",
"VoiceFeedback.rating",
"VoiceFeedback.comment",
"VoiceFeedback.createdAt",
"VoiceSession.roomType",
"VoiceSession.platform",
commonUserJsonObject(eb).as("user"),
])
.where("VoiceFeedback.comment", "is not", null)
.orderBy("VoiceFeedback.id", "desc")
.limit(LATEST_FEEDBACK_LIMIT)
.execute();
}
export async function insertOwnSession(
args: Pick<
TablesInsertable["VoiceSession"],
"roomId" | "roomType" | "platform" | "eligibleMemberCount" | "vcPreference"
>,
) {
const { id } = await db
.insertInto("VoiceSession")
.values({ ...args, userId: actorId() })
.returning("id")
.executeTakeFirstOrThrow();
// so concurrent joins can't both stay open and trip the kill switch in the process
await db
.updateTable("VoiceSession")
.set({ leftAt: databaseTimestampNow(), leaveReason: "REPLACED" })
.where("VoiceSession.userId", "=", actorId())
.where("VoiceSession.id", "!=", id)
.where("VoiceSession.leftAt", "is", null)
.execute();
return id;
}
export async function markOwnSessionConnected({
id,
dailySessionId,
}: {
id: number;
dailySessionId: string;
}) {
await db
.updateTable("VoiceSession")
.set({ connectedAt: databaseTimestampNow(), dailySessionId })
.where("VoiceSession.id", "=", id)
.where("VoiceSession.userId", "=", actorId())
.where("VoiceSession.connectedAt", "is", null)
.execute();
}
export async function updateOwnSessionSummary({
id,
...summary
}: Pick<
Tables["VoiceSession"],
"id" | "lowQualitySeconds" | "talkSeconds" | "inputMode"
>) {
await db
.updateTable("VoiceSession")
.set(summary)
.where("VoiceSession.id", "=", id)
.where("VoiceSession.userId", "=", actorId())
.execute();
}
export async function markOwnSessionLeft({
id,
leaveReason,
}: {
id: number;
leaveReason: VoiceLeaveReason;
}) {
await db
.updateTable("VoiceSession")
.set({ leftAt: databaseTimestampNow(), leaveReason })
.where("VoiceSession.id", "=", id)
.where("VoiceSession.userId", "=", actorId())
.where("VoiceSession.leftAt", "is", null)
.execute();
}
export async function markSessionsKicked({
roomId,
userId,
}: {
roomId: number;
userId: number;
}) {
await db
.updateTable("VoiceSession")
.set({ leftAt: databaseTimestampNow(), leaveReason: "KICKED" })
.where("VoiceSession.roomId", "=", roomId)
.where("VoiceSession.userId", "=", userId)
.where("VoiceSession.leftAt", "is", null)
.execute();
}
export async function updateFromLeftWebhook({
dailySessionId,
durationSeconds,
networkQualityState,
leftAt,
}: {
dailySessionId: string;
durationSeconds: number;
networkQualityState: Tables["VoiceSession"]["networkQualityState"];
leftAt: number;
}) {
await db
.updateTable("VoiceSession")
.set((eb) => ({
durationSeconds,
networkQualityState,
leftAt: eb.fn.coalesce("VoiceSession.leftAt", eb.val(leftAt)),
leaveReason: eb.fn.coalesce(
"VoiceSession.leaveReason",
eb.val<VoiceLeaveReason>("EJECTED"),
),
}))
.where("VoiceSession.dailySessionId", "=", dailySessionId)
.execute();
}
export async function insertOwnClientError(
args: Omit<TablesInsertable["VoiceClientError"], "userId" | "createdAt">,
) {
await db
.insertInto("VoiceClientError")
.values({ ...args, userId: actorId() })
.execute();
}
export async function insertOwnFeedback(
args: Pick<
TablesInsertable["VoiceFeedback"],
"voiceSessionId" | "rating" | "comment"
>,
) {
await db
.insertInto("VoiceFeedback")
.values({ ...args, userId: actorId() })
.onConflict((oc) => oc.column("voiceSessionId").doNothing())
.execute();
}
export async function updateSettings({ isDisabled }: { isDisabled: boolean }) {
await db
.updateTable("VoiceSettings")
.set({
isDisabled: toDBBoolean(isDisabled),
updatedAt: databaseTimestampNow(),
updatedByUserId: actorId(),
})
.execute();
}
export async function deleteOlderThan({
sessionsBefore,
errorsBefore,
}: {
sessionsBefore: number;
errorsBefore: number;
}) {
await db.transaction().execute(async (trx) => {
await trx
.deleteFrom("VoiceClientError")
.where("VoiceClientError.createdAt", "<", errorsBefore)
.execute();
await trx
.deleteFrom("VoiceSession")
.where("VoiceSession.createdAt", "<", sessionsBefore)
.execute();
});
}

View File

@@ -0,0 +1,19 @@
import { defineAction } from "~/form/define-action.server";
import * as VoiceAvailability from "../core/VoiceAvailability.server";
import * as VoiceRepository from "../VoiceRepository.server";
import { voiceDashboardActionSchema } from "../voice-schemas";
import { requireVoiceDashboardAccess } from "../voice-utils.server";
export const action = defineAction(
{ body: voiceDashboardActionSchema },
async ({ body }) => {
requireVoiceDashboardAccess();
await VoiceRepository.updateSettings({
isDisabled: body._action === "DISABLE",
});
VoiceAvailability.clearCache();
return null;
},
);

View File

@@ -0,0 +1,187 @@
.voiceBar {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-1) var(--s-2);
border-bottom: 1.5px solid var(--color-border);
background-color: var(--color-bg-high);
flex-shrink: 0;
font-size: var(--font-xs);
}
.row {
display: flex;
align-items: center;
gap: var(--s-1-5);
min-width: 0;
}
.strip {
flex-direction: row;
align-items: center;
gap: var(--s-1-5);
}
.stripTitle {
display: flex;
align-items: center;
gap: var(--s-1-5);
flex: 1;
min-width: 0;
padding: 0;
background: none;
border: none;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-align: start;
cursor: pointer;
}
.voiceIcon {
flex-shrink: 0;
color: var(--color-text-high);
}
.label {
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.betaBadge {
padding: 0 var(--s-1);
border-radius: var(--radius-full);
background-color: var(--color-accent-low);
color: var(--color-accent-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
}
.actions {
display: flex;
flex-shrink: 0;
gap: var(--s-3);
margin-inline-start: auto;
}
.buttonGroup {
display: flex;
gap: var(--s-1);
}
.mirroredIcon {
transform: scaleX(-1);
}
.pushToTalk {
user-select: none;
touch-action: none;
-webkit-touch-callout: none;
}
.avatarStack {
display: flex;
flex-wrap: wrap;
margin: 0;
padding: 0 0 var(--s-1) var(--s-0-5);
list-style: none;
}
.stackedUser {
position: relative;
display: flex;
& + & {
margin-inline-start: calc(var(--s-1-5) * -1);
}
&:hover,
&:focus-within {
z-index: 1;
}
}
.userButton {
position: relative;
display: flex;
padding: 0;
border: none;
border-radius: var(--radius-full);
background: none;
cursor: pointer;
}
.avatar {
border-radius: var(--radius-full);
box-shadow: 0 0 0 2px var(--color-bg-high);
transition: box-shadow 0.1s;
}
.speaking {
box-shadow:
0 0 0 2px var(--color-bg-high),
0 0 0 4px var(--color-success);
}
.mutedIcon {
position: absolute;
inset-block-end: -2px;
inset-inline-end: -2px;
padding: 1px;
border-radius: var(--radius-full);
background-color: var(--color-bg);
color: var(--color-error);
}
.statusDot {
width: var(--s-2);
height: var(--s-2);
flex-shrink: 0;
border-radius: var(--radius-full);
background-color: var(--color-warning);
}
.statusConnected {
background-color: var(--color-success);
}
.statusFailed {
background-color: var(--color-error);
}
.feedback {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-1-5);
}
.stars {
display: flex;
gap: var(--s-0-5);
}
.star {
display: grid;
place-items: center;
padding: var(--s-0-5);
border: none;
background: none;
color: var(--color-text-high);
cursor: pointer;
}
.starSelected {
color: var(--color-warning);
& svg {
fill: currentColor;
}
}
.comment {
width: 100%;
min-height: 4rem;
font-size: var(--font-xs);
}

View File

@@ -0,0 +1,559 @@
import clsx from "clsx";
import {
HeadphoneOff,
Headphones,
Mic,
MicOff,
Phone,
PhoneOff,
RotateCcw,
Star,
Volume2,
VolumeX,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { useUser } from "~/features/auth/core/user";
import type {
ChatRoomListItem,
ChatRoomType,
} from "~/features/chat/chat-types";
import type { CommonUser } from "~/utils/kysely.server";
import {
type VoiceCall,
type VoiceRoomState,
voiceClient,
} from "../voice-client";
import { VOICE_FEEDBACK_COMMENT_MAX_LENGTH } from "../voice-constants";
import { useVoiceRoom, useVoiceSnapshot } from "../voice-hooks";
import styles from "./VoiceBar.module.css";
const RATINGS = [1, 2, 3, 4, 5];
export function VoiceBar({ room }: { room: ChatRoomListItem }) {
const voiceRoom = useVoiceRoom(room);
const { call, pendingFeedback } = useVoiceSnapshot();
const roomCall = call?.roomId === room.id ? call : null;
const feedback = pendingFeedback?.roomId === room.id ? pendingFeedback : null;
if (!voiceRoom?.available && !roomCall && !feedback) return null;
return (
<div className={styles.voiceBar}>
{roomCall || voiceRoom ? (
<div className={styles.row}>
{roomCall ? (
<CallStatus call={roomCall} />
) : (
<>
<Headphones size={16} className={styles.voiceIcon} />
<VoiceLabel />
</>
)}
<div className={styles.actions}>
{roomCall ? (
<CallButtons call={roomCall} />
) : voiceRoom?.canJoin ? (
<JoinButton roomId={room.id} />
) : null}
</div>
</div>
) : null}
{roomCall ? <CallNotices call={roomCall} /> : null}
<VoiceUsers
call={roomCall}
voiceRoom={voiceRoom}
participants={room.participants}
roomId={room.id}
roomType={room.type}
/>
{feedback ? <FeedbackPrompt /> : null}
</div>
);
}
export function VoiceCallStrip({
title,
onOpen,
}: {
title: string;
onOpen: () => void;
}) {
const { t } = useTranslation(["common"]);
const { call } = useVoiceSnapshot();
if (!call) return null;
return (
<div className={clsx(styles.voiceBar, styles.strip)}>
<button type="button" className={styles.stripTitle} onClick={onOpen}>
<StatusDot status={call.status} />
<span className="truncate">
{t("common:chat.voice.inCall", { room: title })}
</span>
</button>
<div className={styles.actions}>
<CallButtons call={call} />
</div>
</div>
);
}
function VoiceLabel() {
const { t } = useTranslation(["common"]);
return (
<>
<span className={styles.label}>{t("common:chat.voice.title")}</span>
<span className={styles.betaBadge}>{t("common:chat.voice.beta")}</span>
</>
);
}
function CallStatus({ call }: { call: VoiceCall }) {
const { t } = useTranslation(["common"]);
return (
<>
<StatusDot status={call.status} />
{call.status === "FAILED" ? (
<span className={clsx(styles.label, "text-error")}>
{t("common:chat.voice.failed")}
</span>
) : call.status === "CONNECTED" ? (
<VoiceLabel />
) : (
<span className={styles.label}>
{t("common:chat.voice.connecting")}
</span>
)}
</>
);
}
function CallButtons({ call }: { call: VoiceCall }) {
return (
<>
{call.status === "FAILED" ? (
<RetryButton roomId={call.roomId} />
) : (
<div className={styles.buttonGroup}>
<MicButton call={call} />
<DeafenButton call={call} />
</div>
)}
<LeaveButton />
</>
);
}
function CallNotices({ call }: { call: VoiceCall }) {
const { t } = useTranslation(["common"]);
return (
<>
{call.audioBlocked ? (
<SendouButton
size="small"
variant="primary"
icon={<Volume2 />}
onClick={() => voiceClient.resumeAudio()}
>
{t("common:chat.voice.enableSound")}
</SendouButton>
) : null}
{call.errorKind === "MIC_DENIED" ? (
<span className="text-warning text-xs">
{t("common:chat.voice.micDenied")}
</span>
) : call.errorKind === "MIC_NOT_FOUND" ? (
<span className="text-warning text-xs">
{t("common:chat.voice.micNotFound")}
</span>
) : null}
</>
);
}
function JoinButton({ roomId }: { roomId: number }) {
const { t } = useTranslation(["common"]);
return (
<SendouButton
size="small"
shape="square"
variant="outlined-success"
icon={<Phone />}
aria-label={t("common:chat.voice.join")}
title={t("common:chat.voice.join")}
onClick={() => void voiceClient.join(roomId)}
/>
);
}
function RetryButton({ roomId }: { roomId: number }) {
const { t } = useTranslation(["common"]);
return (
<SendouButton
size="small"
shape="square"
variant="minimal"
icon={<RotateCcw />}
aria-label={t("common:chat.voice.retry")}
title={t("common:chat.voice.retry")}
onClick={() => void voiceClient.join(roomId)}
/>
);
}
function MicButton({ call }: { call: VoiceCall }) {
const { t } = useTranslation(["common"]);
const isPushToTalk = call.inputMode === "PUSH_TO_TALK";
const label = isPushToTalk
? t("common:chat.voice.holdToTalk")
: call.micEnabled
? t("common:chat.voice.mute")
: t("common:chat.voice.unmute");
const setTalking = (talking: boolean) => {
if (call.micEnabled !== talking) voiceClient.setMicEnabled(talking);
};
const pushToTalkHandlers: React.ComponentProps<typeof SendouButton> = {
onPointerDown: () => setTalking(true),
onPointerUp: () => setTalking(false),
onPointerCancel: () => setTalking(false),
onPointerLeave: () => setTalking(false),
onKeyDown: (event) => {
if (event.key === " " || event.key === "Enter") setTalking(true);
},
onKeyUp: () => setTalking(false),
onContextMenu: (event) => event.preventDefault(),
};
return (
<SendouButton
size="small"
shape="square"
variant={call.micEnabled ? "minimal-success" : "minimal-destructive"}
icon={
call.micEnabled ? <Mic /> : <MicOff className={styles.mirroredIcon} />
}
isDisabled={call.status !== "CONNECTED"}
aria-label={label}
aria-pressed={call.micEnabled}
title={label}
className={isPushToTalk ? styles.pushToTalk : undefined}
{...(isPushToTalk
? pushToTalkHandlers
: { onClick: () => voiceClient.setMicEnabled(!call.micEnabled) })}
/>
);
}
function DeafenButton({ call }: { call: VoiceCall }) {
const { t } = useTranslation(["common"]);
const label = call.deafened
? t("common:chat.voice.undeafen")
: t("common:chat.voice.deafen");
return (
<SendouButton
size="small"
shape="square"
variant={call.deafened ? "minimal-destructive" : "minimal-success"}
icon={
call.deafened ? (
<HeadphoneOff className={styles.mirroredIcon} />
) : (
<Headphones />
)
}
isDisabled={call.status !== "CONNECTED"}
aria-label={label}
aria-pressed={call.deafened}
title={label}
onClick={() => voiceClient.setDeafened(!call.deafened)}
/>
);
}
function LeaveButton() {
const { t } = useTranslation(["common"]);
return (
<SendouButton
size="small"
shape="square"
variant="outlined-destructive"
icon={<PhoneOff />}
aria-label={t("common:chat.voice.leave")}
title={t("common:chat.voice.leave")}
onClick={() => void voiceClient.leave()}
/>
);
}
function VoiceUsers({
call,
voiceRoom,
participants,
roomId,
roomType,
}: {
call: VoiceCall | null;
voiceRoom: VoiceRoomState | null;
participants: CommonUser[];
roomId: number;
roomType: ChatRoomType;
}) {
const { t } = useTranslation(["common"]);
const entries = call
? call.participants.map((participant) => ({
key: participant.sessionId,
userId: participant.userId,
isLocal: participant.isLocal,
speaking: call.speakingSessionIds.includes(participant.sessionId),
}))
: (voiceRoom?.userIds ?? []).map((userId) => ({
key: String(userId),
userId,
isLocal: false,
speaking: false,
}));
if (entries.length === 0) return null;
return (
<ul
className={styles.avatarStack}
aria-label={t("common:chat.voice.inVoice", { count: entries.length })}
>
{entries.map((entry) => {
const user = participants.find(
(candidate) => candidate.id === entry.userId,
);
return (
<li key={entry.key} className={styles.stackedUser}>
{call && !entry.isLocal ? (
<RemoteUserControls
userId={entry.userId}
user={user}
speaking={entry.speaking}
canKick={voiceRoom?.canKick ?? false}
roomId={roomId}
roomType={roomType}
/>
) : (
<UserAvatar
userId={entry.userId}
user={user}
speaking={entry.speaking}
/>
)}
</li>
);
})}
</ul>
);
}
function UserAvatar({
userId,
user,
speaking,
}: {
userId: number;
user: CommonUser | undefined;
speaking: boolean;
}) {
return (
<Avatar
size="xxs"
user={user}
identiconInput={user ? undefined : String(userId)}
alt={user?.username ?? ""}
title={user?.username}
className={clsx(styles.avatar, speaking && styles.speaking)}
/>
);
}
function RemoteUserControls({
userId,
user,
speaking,
canKick,
roomId,
roomType,
}: {
userId: number;
user: CommonUser | undefined;
speaking: boolean;
canKick: boolean;
roomId: number;
roomType: ChatRoomType;
}) {
const { t } = useTranslation(["common"]);
const [isKickConfirmOpen, setIsKickConfirmOpen] = React.useState(false);
const { volumeByUserId } = useVoiceSnapshot();
const volume = volumeByUserId.get(userId) ?? 1;
const muteLabel =
volume === 0
? t("common:chat.voice.unmuteUser")
: t("common:chat.voice.muteUser");
return (
<>
<SendouPopover
trigger={
<button
type="button"
className={styles.userButton}
aria-label={user?.username ?? t("common:chat.voice.title")}
>
<UserAvatar userId={userId} user={user} speaking={speaking} />
{volume === 0 ? (
<VolumeX size={12} className={styles.mutedIcon} />
) : null}
</button>
}
>
<div className="stack sm">
<span className="font-semi-bold">{user?.username ?? "?"}</span>
<div className="stack horizontal xs items-center">
<SendouButton
size="small"
shape="square"
variant="minimal"
icon={volume === 0 ? <VolumeX /> : <Volume2 />}
aria-label={muteLabel}
title={muteLabel}
onClick={() =>
voiceClient.setVolume(userId, volume === 0 ? 1 : 0)
}
/>
<input
type="range"
min={0}
max={100}
value={Math.round(volume * 100)}
aria-label={t("common:chat.voice.volume")}
onChange={(event) =>
voiceClient.setVolume(userId, Number(event.target.value) / 100)
}
/>
</div>
{canKick ? (
<SendouButton
size="small"
variant="minimal-destructive"
onClick={() => setIsKickConfirmOpen(true)}
>
{t("common:chat.voice.kick")}
</SendouButton>
) : null}
</div>
</SendouPopover>
{canKick ? (
<FormWithConfirm
isOpen={isKickConfirmOpen}
onOpenChange={setIsKickConfirmOpen}
dialogHeading={t("common:chat.voice.kickConfirm.heading", {
user: user?.username ?? "?",
})}
description={
roomType === "SQ_MATCH"
? t("common:chat.voice.kickConfirm.set")
: t("common:chat.voice.kickConfirm.group")
}
submitButtonText={t("common:chat.voice.kick")}
onConfirm={() => void voiceClient.kick(roomId, userId)}
/>
) : null}
</>
);
}
function StatusDot({ status }: { status: VoiceCall["status"] }) {
return (
<span
className={clsx(styles.statusDot, {
[styles.statusConnected]: status === "CONNECTED",
[styles.statusFailed]: status === "FAILED",
})}
/>
);
}
function FeedbackPrompt() {
const { t } = useTranslation(["common"]);
const user = useUser();
const [rating, setRating] = React.useState<number | null>(null);
const [comment, setComment] = React.useState("");
if (!user) return null;
return (
<div className={styles.feedback}>
<span className="text-sm font-semi-bold">
{t("common:chat.voice.feedback.question")}
</span>
<div className={styles.stars}>
{RATINGS.map((value) => (
<button
key={value}
type="button"
className={clsx(
styles.star,
rating !== null && value <= rating && styles.starSelected,
)}
aria-label={t("common:chat.voice.feedback.rating", {
rating: value,
})}
aria-pressed={rating === value}
onClick={() => setRating(value)}
>
<Star size={18} />
</button>
))}
</div>
{rating !== null ? (
<>
<textarea
className={styles.comment}
value={comment}
maxLength={VOICE_FEEDBACK_COMMENT_MAX_LENGTH}
placeholder={t("common:chat.voice.feedback.comment")}
onChange={(event) => setComment(event.target.value)}
/>
<SendouButton
size="small"
onClick={() =>
void voiceClient.submitFeedback({
rating,
comment: comment.trim() || null,
})
}
>
{t("common:actions.submit")}
</SendouButton>
</>
) : null}
<SendouButton
size="small"
variant="minimal"
onClick={() => voiceClient.dismissFeedback()}
>
{t("common:actions.dismiss")}
</SendouButton>
</div>
);
}

View File

@@ -0,0 +1,346 @@
import Daily, {
type DailyCall,
type DailyEventObjectCameraError,
type DailyEventObjectFatalError,
} from "@daily-co/daily-js";
import {
DailyAudioTrack,
DailyProvider,
useAudioLevelObserver,
useDaily,
useDailyEvent,
useNetwork,
useParticipantIds,
} from "@daily-co/daily-react";
import * as React from "react";
import { logger } from "~/utils/logger";
import { type VoiceCallParticipant, voiceClient } from "../voice-client";
import { VOICE_TELEMETRY_INTERVAL_MS } from "../voice-constants";
import { useVoiceSnapshot } from "../voice-hooks";
const SPEAKING_LEVEL = 0.04;
const TELEMETRY_TICK_MS = 1_000;
const audioElements = new Set<HTMLAudioElement>();
let previousTeardown: Promise<void> = Promise.resolve();
export function VoiceCall({
roomId,
roomUrl,
token,
}: {
roomId: number;
roomUrl: string;
token: string;
}) {
const [callObject, setCallObject] = React.useState<DailyCall | null>(null);
React.useEffect(() => {
let unmounted = false;
let created: DailyCall | null = null;
void previousTeardown.then(() => {
if (unmounted) return;
created = Daily.createCallObject({
audioSource: true,
videoSource: false,
startVideoOff: true,
startAudioOff: true,
subscribeToTracksAutomatically: true,
});
setCallObject(created);
});
return () => {
unmounted = true;
previousTeardown = previousTeardown
.then(() => (created ? teardownCallObject(created) : undefined))
.catch((error: unknown) => {
logger.error("Tearing down the voice call failed", error);
});
};
}, []);
React.useEffect(() => {
if (!callObject) return;
let superseded = false;
callObject.setLocalVideo(false);
voiceClient.registerCallHandle({
setLocalAudio: (enabled) => callObject.setLocalAudio(enabled),
leave: () => callObject.leave(),
resumeAudio: () => {
for (const audio of audioElements) {
void audio.play().catch(() => {});
}
},
});
callObject
.join({ url: roomUrl, token, startVideoOff: true, startAudioOff: true })
.then(() => {
if (superseded) return;
voiceClient.markConnected(callObject.participants().local.session_id);
})
.catch((error: unknown) => {
if (superseded) return;
logger.error("Joining the voice call failed", error);
voiceClient.markFailed("JOIN_FAILED", String(error));
});
return () => {
superseded = true;
voiceClient.registerCallHandle(null);
};
}, [callObject, roomUrl, token]);
if (!callObject) return null;
return (
<DailyProvider callObject={callObject}>
<CallEvents roomId={roomId} />
<ParticipantsSync />
<RemoteAudio />
<Telemetry />
<ScreenWakeLock />
</DailyProvider>
);
}
function CallEvents({ roomId }: { roomId: number }) {
useDailyEvent(
"error",
React.useCallback(
(event: DailyEventObjectFatalError) => {
switch (event.error?.type) {
case "exp-token": {
void voiceClient.leave("TOKEN_EXPIRED").then(() => {
if (document.visibilityState === "visible") {
void voiceClient.join(roomId);
}
});
return;
}
case "ejected":
case "exp-room": {
void voiceClient.leave("EJECTED");
return;
}
case "connection-error": {
void voiceClient.leave("CONNECTION_LOST");
return;
}
default: {
voiceClient.markFailed("JOIN_FAILED", event.errorMsg);
}
}
},
[roomId],
),
);
useDailyEvent(
"camera-error",
React.useCallback((event: DailyEventObjectCameraError) => {
const { error } = event;
if (error.type === "permissions") {
voiceClient.flagError("MIC_DENIED", error.msg);
} else if (error.type === "not-found") {
voiceClient.flagError("MIC_NOT_FOUND", error.msg);
}
}, []),
);
return null;
}
function ParticipantsSync() {
const daily = useDaily();
const sessionIds = useParticipantIds();
const sync = React.useCallback(() => {
if (daily) voiceClient.updateParticipants(participantsOf(daily));
}, [daily]);
useDailyEvent("participant-joined", sync);
useDailyEvent("participant-updated", sync);
useDailyEvent("participant-left", sync);
useDailyEvent("joined-meeting", sync);
return (
<>
{sessionIds.map((sessionId) => (
<SpeakingObserver key={sessionId} sessionId={sessionId} />
))}
</>
);
}
function SpeakingObserver({ sessionId }: { sessionId: string }) {
useAudioLevelObserver(
sessionId,
React.useCallback(
(level: number) =>
voiceClient.setSpeaking(sessionId, level > SPEAKING_LEVEL),
[sessionId],
),
);
React.useEffect(
() => () => voiceClient.setSpeaking(sessionId, false),
[sessionId],
);
return null;
}
function RemoteAudio() {
const daily = useDaily();
const remoteSessionIds = useParticipantIds({ filter: "remote" });
return (
<>
{remoteSessionIds.map((sessionId) => (
<ParticipantAudio
key={sessionId}
sessionId={sessionId}
userId={Number(daily?.participants()[sessionId]?.user_id)}
/>
))}
</>
);
}
function ParticipantAudio({
sessionId,
userId,
}: {
sessionId: string;
userId: number;
}) {
const { volumeByUserId, call } = useVoiceSnapshot();
const audioRef = React.useRef<HTMLAudioElement>(null);
const volume = volumeByUserId.get(userId) ?? 1;
const deafened = call?.deafened ?? false;
React.useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
// iOS ignores `volume` (lol), muting at least works everywhere
audio.volume = volume;
audio.muted = volume === 0 || deafened;
}, [volume, deafened]);
React.useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audioElements.add(audio);
return () => {
audioElements.delete(audio);
};
}, []);
return (
<DailyAudioTrack
ref={audioRef}
sessionId={sessionId}
onPlayFailed={(event) => {
if (event.name === "NotAllowedError") {
voiceClient.setAudioBlocked(true);
voiceClient.flagError("AUTOPLAY_BLOCKED", event.message ?? null);
}
}}
/>
);
}
function Telemetry() {
const { networkState } = useNetwork();
const networkStateRef = React.useRef(networkState);
networkStateRef.current = networkState;
React.useEffect(() => {
const totals = { lowQualitySeconds: 0, talkSeconds: 0 };
const tick = setInterval(() => {
const call = voiceClient.getSnapshot().call;
if (call?.status !== "CONNECTED") return;
if (
networkStateRef.current === "bad" ||
networkStateRef.current === "warning"
) {
totals.lowQualitySeconds++;
}
if (call.micEnabled) totals.talkSeconds++;
voiceClient.setSummary({ ...totals });
}, TELEMETRY_TICK_MS);
const flush = setInterval(
() => voiceClient.flushSummary(),
VOICE_TELEMETRY_INTERVAL_MS,
);
return () => {
clearInterval(tick);
clearInterval(flush);
};
}, []);
return null;
}
function ScreenWakeLock() {
React.useEffect(() => {
if (!("wakeLock" in navigator)) return;
let sentinel: WakeLockSentinel | null = null;
let released = false;
const acquire = async () => {
if (released || document.visibilityState !== "visible") return;
try {
sentinel = await navigator.wakeLock.request("screen");
} catch (error) {
logger.warn("Screen wake lock unavailable", error);
}
};
void acquire();
document.addEventListener("visibilitychange", acquire);
return () => {
released = true;
document.removeEventListener("visibilitychange", acquire);
void sentinel?.release().catch(() => {});
};
}, []);
return null;
}
function participantsOf(daily: DailyCall): VoiceCallParticipant[] {
return Object.values(daily.participants()).flatMap((participant) => {
const userId = Number(participant.user_id);
if (!Number.isInteger(userId)) return [];
return {
userId,
sessionId: participant.session_id,
isLocal: participant.local,
audioEnabled:
participant.tracks.audio.state === "playable" ||
participant.tracks.audio.state === "sendable",
};
});
}
async function teardownCallObject(callObject: DailyCall) {
try {
await callObject.leave();
} finally {
await callObject.destroy();
}
}

View File

@@ -0,0 +1,40 @@
import * as React from "react";
import { useUser } from "~/features/auth/core/user";
import { voiceClient } from "../voice-client";
import { useVoiceSnapshot } from "../voice-hooks";
const VoiceCall = React.lazy(() =>
import("./VoiceCall").then((module) => ({ default: module.VoiceCall })),
);
export function VoiceCallHost() {
const { call } = useVoiceSnapshot();
const user = useUser();
const inputMode = user?.preferences.voicePushToTalk
? "PUSH_TO_TALK"
: "OPEN_MIC";
React.useEffect(() => voiceClient.start(), []);
React.useEffect(() => voiceClient.setInputMode(inputMode), [inputMode]);
React.useEffect(() => {
const handlePageHide = () => voiceClient.leaveOnPageClose();
window.addEventListener("pagehide", handlePageHide);
return () => window.removeEventListener("pagehide", handlePageHide);
}, []);
if (!call?.credentials || !call.voiceSessionId) return null;
return (
<React.Suspense fallback={null}>
<VoiceCall
key={call.voiceSessionId}
roomId={call.roomId}
roomUrl={call.credentials.roomUrl}
token={call.credentials.token}
/>
</React.Suspense>
);
}

View File

@@ -0,0 +1,70 @@
import { createHmac } from "node:crypto";
import { describe, expect, test } from "vitest";
import * as Daily from "./Daily.server";
const SECRET = Buffer.from("a webhook secret of some length").toString(
"base64",
);
const TIMESTAMP = "1728000000";
const BODY = JSON.stringify({ type: "participant.left", payload: {} });
function dailySignature(body: string, timestamp: string) {
return createHmac("sha256", Buffer.from(SECRET, "base64"))
.update(`${timestamp}.${body}`)
.digest("base64");
}
describe("Daily.isValidWebhookSignature", () => {
test.each([
{
why: "a request signed by Daily",
rawBody: BODY,
timestamp: TIMESTAMP,
signature: dailySignature(BODY, TIMESTAMP),
expected: true,
},
{
why: "a tampered body",
rawBody: `${BODY} `,
timestamp: TIMESTAMP,
signature: dailySignature(BODY, TIMESTAMP),
expected: false,
},
{
why: "a replayed signature with another timestamp",
rawBody: BODY,
timestamp: "1728000001",
signature: dailySignature(BODY, TIMESTAMP),
expected: false,
},
{
why: "a missing signature",
rawBody: BODY,
timestamp: TIMESTAMP,
signature: "",
expected: false,
},
])("$why: $expected", ({ rawBody, timestamp, signature, expected }) => {
expect(
Daily.isValidWebhookSignature({
rawBody,
timestamp,
signature,
secret: SECRET,
}),
).toBe(expected);
});
});
describe("Daily.chatRoomIdFromRoomName", () => {
test("reads back the chat room of its own room names", () => {
expect(Daily.chatRoomIdFromRoomName(Daily.roomName(42))).toBe(42);
});
test.each([
{ why: "another environment's room", name: "chat-x-42" },
{ why: "a room named by hand", name: "standup" },
])("returns null for $why", ({ name }) => {
expect(Daily.chatRoomIdFromRoomName(name)).toBeNull();
});
});

View File

@@ -0,0 +1,194 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import * as v from "valibot";
import { ServerConfig } from "~/config.server";
import { logger } from "~/utils/logger";
const API_URL = "https://api.daily.co/v1";
const REQUEST_TIMEOUT_MS = 8_000;
const ROOM_NAME_PREFIX = ServerConfig.isProduction ? "chat" : "dev-chat";
const presenceSchema = v.object({
data: v.array(
v.object({
userId: v.nullish(v.string()),
}),
),
});
const tokenSchema = v.object({ token: v.string() });
export function isConfigured() {
return Boolean(ServerConfig.daily.apiKey && ServerConfig.daily.domain);
}
export function roomName(chatRoomId: number) {
return `${ROOM_NAME_PREFIX}-${chatRoomId}`;
}
export function chatRoomIdFromRoomName(name: string) {
const match = new RegExp(`^${ROOM_NAME_PREFIX}-(\\d+)$`).exec(name);
if (!match) return null;
return Number(match[1]);
}
export function roomUrl(chatRoomId: number) {
return `https://${ServerConfig.daily.domain}.daily.co/${roomName(chatRoomId)}`;
}
export async function ensureRoom({
chatRoomId,
expiresAt,
}: {
chatRoomId: number;
expiresAt: number;
}) {
const response = await request("/rooms", {
method: "POST",
body: {
name: roomName(chatRoomId),
privacy: "private",
properties: {
exp: expiresAt,
eject_at_room_exp: true,
start_video_off: true,
enable_screenshare: false,
enable_chat: false,
enable_prejoin_ui: false,
permissions: { canSend: ["audio"] },
},
},
allowFailure: true,
});
if (response.ok) return;
const body = await response.text();
if (!body.includes("already exists")) {
throw new Error(`Daily room creation failed (${response.status}): ${body}`);
}
// Extend the expiry in case the group decides to re-enter Q
await request(`/rooms/${roomName(chatRoomId)}`, {
method: "POST",
body: { properties: { exp: expiresAt } },
});
}
export async function createMeetingToken({
chatRoomId,
userId,
userName,
expiresAt,
}: {
chatRoomId: number;
userId: number;
userName: string;
expiresAt: number;
}) {
const response = await request("/meeting-tokens", {
method: "POST",
body: {
properties: {
room_name: roomName(chatRoomId),
user_id: String(userId),
user_name: userName,
exp: expiresAt,
eject_at_token_exp: true,
start_video_off: true,
permissions: { canSend: ["audio"], hasPresence: true },
},
},
});
return v.parse(tokenSchema, await response.json()).token;
}
export async function ejectUsers({
chatRoomId,
userIds,
ban,
}: {
chatRoomId: number;
userIds: number[];
ban: boolean;
}) {
if (userIds.length === 0) return;
await request(`/rooms/${roomName(chatRoomId)}/eject`, {
method: "POST",
body: { user_ids: userIds.map(String), ban },
});
}
export async function findPresentUserIds(chatRoomId: number) {
const response = await request(`/rooms/${roomName(chatRoomId)}/presence`, {
method: "GET",
allowFailure: true,
});
if (response.status === 404) return [];
if (!response.ok) {
throw new Error(`Daily presence fetch failed (${response.status})`);
}
const { data } = v.parse(presenceSchema, await response.json());
return data.flatMap((participant) => {
const userId = Number(participant.userId);
return Number.isInteger(userId) && userId > 0 ? [userId] : [];
});
}
export function isValidWebhookSignature({
rawBody,
timestamp,
signature,
secret,
}: {
rawBody: string;
timestamp: string;
signature: string;
secret: string;
}) {
const expected = createHmac("sha256", Buffer.from(secret, "base64"))
.update(`${timestamp}.${rawBody}`)
.digest();
const received = Buffer.from(signature, "base64");
return (
received.length === expected.length && timingSafeEqual(received, expected)
);
}
async function request(
path: string,
{
method,
body,
allowFailure = false,
}: {
method: "GET" | "POST";
body?: unknown;
allowFailure?: boolean;
},
) {
const response = await fetch(`${API_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${ServerConfig.daily.apiKey}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok && !allowFailure) {
const text = await response.text();
logger.error(
`Daily ${method} ${path} failed (${response.status}): ${text}`,
);
throw new Error(`Daily request failed (${response.status})`);
}
return response;
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, test } from "vitest";
import type { ResolvedRoom } from "~/features/chat/ChatRoomResolver.server";
import * as VoiceAccess from "./VoiceAccess";
const PLAYER_ID = 1;
const TEAMMATE_ID = 2;
const STAFF_ID = 3;
const OUTSIDER_ID = 4;
function resolvedRoom(overrides: Partial<ResolvedRoom> = {}) {
return {
type: "SQ_MATCH" as const,
inactive: false,
participantUserIds: [PLAYER_ID, TEAMMATE_ID],
permissions: {
VIEW: [PLAYER_ID, TEAMMATE_ID, STAFF_ID],
POST: [PLAYER_ID, TEAMMATE_ID, STAFF_ID],
OBSERVE: [STAFF_ID],
},
...overrides,
};
}
describe("VoiceAccess.canJoin", () => {
test.each([
{
why: "a participant of a SendouQ match",
room: resolvedRoom(),
userId: PLAYER_ID,
expected: true,
},
{
why: "staff observing a SendouQ match",
room: resolvedRoom(),
userId: STAFF_ID,
expected: true,
},
{
why: "someone who may not post",
room: resolvedRoom(),
userId: OUTSIDER_ID,
expected: false,
},
{
why: "a SendouQ group member",
room: resolvedRoom({
type: "SQ_GROUP",
permissions: {
VIEW: [PLAYER_ID, TEAMMATE_ID, STAFF_ID],
POST: [PLAYER_ID, TEAMMATE_ID],
OBSERVE: [STAFF_ID],
},
}),
userId: PLAYER_ID,
expected: true,
},
{
why: "staff observing a private SendouQ group",
room: resolvedRoom({
type: "SQ_GROUP",
permissions: {
VIEW: [PLAYER_ID, TEAMMATE_ID, STAFF_ID],
POST: [PLAYER_ID, TEAMMATE_ID],
OBSERVE: [STAFF_ID],
},
}),
userId: STAFF_ID,
expected: false,
},
{
why: "a tournament match",
room: resolvedRoom({ type: "TOURNAMENT_MATCH" }),
userId: PLAYER_ID,
expected: false,
},
{
why: "a scrim",
room: resolvedRoom({ type: "SCRIM" }),
userId: PLAYER_ID,
expected: false,
},
{
why: "a finished match",
room: resolvedRoom({ inactive: true }),
userId: PLAYER_ID,
expected: false,
},
{
why: "a solo group",
room: resolvedRoom({ type: "SQ_GROUP", participantUserIds: [PLAYER_ID] }),
userId: PLAYER_ID,
expected: false,
},
])("$why: $expected", ({ room, userId, expected }) => {
expect(VoiceAccess.canJoin(room, userId)).toBe(expected);
});
});
describe("VoiceAccess.canKick", () => {
test.each([
{ why: "staff in a SendouQ match", userId: STAFF_ID, expected: true },
{ why: "a player", userId: PLAYER_ID, expected: false },
{ why: "an outsider", userId: OUTSIDER_ID, expected: false },
])("$why: $expected", ({ userId, expected }) => {
expect(VoiceAccess.canKick(resolvedRoom(), userId)).toBe(expected);
});
});

View File

@@ -0,0 +1,24 @@
import type { ResolvedRoom } from "~/features/chat/ChatRoomResolver.server";
import type { ChatRoomType } from "~/features/chat/chat-types";
import { VOICE_ROOM_TYPES } from "../voice-constants";
type VoiceRoom = Pick<
ResolvedRoom,
"type" | "inactive" | "participantUserIds" | "permissions"
>;
export function isVoiceRoomType(type: ChatRoomType) {
return VOICE_ROOM_TYPES.some((voiceType) => voiceType === type);
}
export function canJoin(room: VoiceRoom, userId: number) {
if (!isVoiceRoomType(room.type)) return false;
if (room.inactive) return false;
if (room.participantUserIds.length < 2) return false;
return room.permissions.POST.includes(userId);
}
export function canKick(room: VoiceRoom, userId: number) {
return canJoin(room, userId) && room.permissions.OBSERVE.includes(userId);
}

View File

@@ -0,0 +1,35 @@
import { startOfMonth } from "date-fns";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import * as VoiceRepository from "../VoiceRepository.server";
import * as Daily from "./Daily.server";
import * as VoiceCost from "./VoiceCost";
const CACHE_MS = 60_000;
let cached: { available: boolean; checkedAt: number } | null = null;
export async function isAvailable() {
if (!Daily.isConfigured()) return false;
if (cached && Date.now() - cached.checkedAt < CACHE_MS) {
return cached.available;
}
const [settings, minutes] = await Promise.all([
VoiceRepository.findSettings(),
monthParticipantMinutes(),
]);
const available = !settings.isDisabled && !VoiceCost.isOverBudget(minutes);
cached = { available, checkedAt: Date.now() };
return available;
}
export function clearCache() {
cached = null;
}
export function monthParticipantMinutes() {
return VoiceRepository.sumParticipantMinutesSince(
dateToDatabaseTimestamp(startOfMonth(new Date())),
);
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import * as VoiceCost from "./VoiceCost";
describe("VoiceCost.monthlyCostUsd", () => {
test.each([
{ why: "no usage", minutes: 0, expected: 0 },
{ why: "within the free minutes", minutes: 10_000, expected: 0 },
{ why: "past the free minutes", minutes: 20_000, expected: 9.9 },
])("$why", ({ minutes, expected }) => {
expect(VoiceCost.monthlyCostUsd(minutes)).toBeCloseTo(expected);
});
});
describe("VoiceCost.isOverBudget", () => {
test.each([
{ why: "a cheap month", minutes: 100_000, expected: false },
{ why: "a month at the budget", minutes: 515_051, expected: true },
])("$why", ({ minutes, expected }) => {
expect(VoiceCost.isOverBudget(minutes)).toBe(expected);
});
});
describe("VoiceCost.projectedMonthlyCostUsd", () => {
test.each([
{ why: "the month just started", minutes: 1_000, progress: 0, expected: 0 },
{
why: "half the month used the free minutes",
minutes: 10_000,
progress: 0.5,
expected: 9.9,
},
])("$why", ({ minutes, progress, expected }) => {
expect(
VoiceCost.projectedMonthlyCostUsd({
participantMinutes: minutes,
monthProgress: progress,
}),
).toBeCloseTo(expected);
});
});

View File

@@ -0,0 +1,30 @@
import {
VOICE_FREE_MINUTES_PER_MONTH,
VOICE_MONTHLY_BUDGET_USD,
VOICE_PRICE_PER_MINUTE_USD,
} from "../voice-constants";
export function monthlyCostUsd(participantMinutes: number) {
const billable = Math.max(
0,
participantMinutes - VOICE_FREE_MINUTES_PER_MONTH,
);
return billable * VOICE_PRICE_PER_MINUTE_USD;
}
export function isOverBudget(participantMinutes: number) {
return monthlyCostUsd(participantMinutes) >= VOICE_MONTHLY_BUDGET_USD;
}
export function projectedMonthlyCostUsd({
participantMinutes,
monthProgress,
}: {
participantMinutes: number;
monthProgress: number;
}) {
if (monthProgress <= 0) return 0;
return monthlyCostUsd(participantMinutes / monthProgress);
}

View File

@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as Daily from "./Daily.server";
import * as VoicePresence from "./VoicePresence.server";
vi.mock("./Daily.server", () => ({
findPresentUserIds: vi.fn(),
}));
const findPresentUserIds = vi.mocked(Daily.findPresentUserIds);
let nextRoomId = 1;
const newRoom = () => ({ roomId: nextRoomId++, participantUserIds: [5, 6] });
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
});
afterEach(() => {
vi.useRealTimers();
findPresentUserIds.mockReset();
});
describe("VoicePresence.reconciledUserIdsOf", () => {
test("keeps a user who joined while Daily was being asked", async () => {
const room = newRoom();
const daily = deferred<number[]>();
findPresentUserIds.mockReturnValue(daily.promise);
const reconciling = VoicePresence.reconciledUserIdsOf(room);
VoicePresence.add(room, 5);
daily.resolve([]);
expect(await reconciling).toEqual([5]);
});
test("keeps a user Daily doesn't list yet right after joining, until the grace period ends", async () => {
const room = newRoom();
findPresentUserIds.mockResolvedValue([]);
VoicePresence.add(room, 5);
expect(await VoicePresence.reconciledUserIdsOf(room)).toEqual([5]);
vi.advanceTimersByTime(60_000);
expect(await VoicePresence.reconciledUserIdsOf(room)).toEqual([]);
});
test("drops a user Daily still lists right after they left", async () => {
const room = newRoom();
findPresentUserIds.mockResolvedValue([5, 6]);
VoicePresence.add(room, 5);
VoicePresence.remove(room, [5]);
expect(await VoicePresence.reconciledUserIdsOf(room)).toEqual([6]);
});
test("keeps Daily's list when the room emptied during the fetch", async () => {
const room = newRoom();
VoicePresence.add(room, 5);
vi.advanceTimersByTime(60_000);
const daily = deferred<number[]>();
findPresentUserIds.mockReturnValue(daily.promise);
const reconciling = VoicePresence.reconciledUserIdsOf(room);
VoicePresence.remove(room, [5]);
daily.resolve([5, 6]);
await reconciling;
expect(
VoicePresence.allOccupiedRooms().find(
(occupied) => occupied.roomId === room.roomId,
)?.userIds,
).toEqual([6]);
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}

View File

@@ -0,0 +1,138 @@
import * as R from "remeda";
import type { ResolvedRoom } from "~/features/chat/ChatRoomResolver.server";
import * as EventBus from "~/features/events/core/EventBus.server";
import { chatRoomChannel, userChannel } from "~/features/events/events-types";
import { logger } from "~/utils/logger";
import { VOICE_PRESENCE_RECONCILE_INTERVAL_MS } from "../voice-constants";
import * as Daily from "./Daily.server";
// Daily's presence lags a moment behind joins and leaves
const PRESENCE_GRACE_MS = 15_000;
type PresenceRoom = Pick<ResolvedRoom, "roomId" | "participantUserIds">;
interface RoomPresence {
userIds: Set<number>;
reconciledAt: number;
}
const presenceByRoomId = new Map<number, RoomPresence>();
const joinedAtByRoomUser = new Map<string, number>();
const leftAtByRoomUser = new Map<string, number>();
export function allOccupiedRooms() {
return [...presenceByRoomId.entries()]
.filter(([, presence]) => presence.userIds.size > 0)
.map(([roomId, presence]) => ({ roomId, userIds: [...presence.userIds] }));
}
export function add(room: PresenceRoom, userId: number) {
pruneGraceEntries();
leftAtByRoomUser.delete(roomUserKey(room.roomId, userId));
joinedAtByRoomUser.set(roomUserKey(room.roomId, userId), Date.now());
const presence = presenceOf(room.roomId);
if (presence.userIds.has(userId)) return;
presence.userIds.add(userId);
publish(room, presence);
}
export function remove(room: PresenceRoom, userIds: number[]) {
const presence = presenceByRoomId.get(room.roomId);
if (!presence) return;
pruneGraceEntries();
for (const userId of userIds) {
joinedAtByRoomUser.delete(roomUserKey(room.roomId, userId));
leftAtByRoomUser.set(roomUserKey(room.roomId, userId), Date.now());
}
const removed = userIds.filter((userId) => presence.userIds.delete(userId));
if (removed.length === 0) return;
publish(room, presence);
if (presence.userIds.size === 0) presenceByRoomId.delete(room.roomId);
}
export async function reconciledUserIdsOf(room: PresenceRoom) {
const presence = presenceOf(room.roomId);
if (
Date.now() - presence.reconciledAt <
VOICE_PRESENCE_RECONCILE_INTERVAL_MS
) {
return [...presence.userIds];
}
presence.reconciledAt = Date.now();
try {
const dailyUserIds = await Daily.findPresentUserIds(room.roomId);
// joins and leaves may have landed during the fetch, possibly replacing the entry
const current = presenceOf(room.roomId);
current.reconciledAt = presence.reconciledAt;
const userIds = R.unique([
...dailyUserIds.filter(
(userId) => !isWithinGrace(leftAtByRoomUser, room.roomId, userId),
),
...[...current.userIds].filter((userId) =>
isWithinGrace(joinedAtByRoomUser, room.roomId, userId),
),
]);
const changed = !R.isDeepEqual(
[...current.userIds].sort(),
[...userIds].sort(),
);
current.userIds = new Set(userIds);
if (changed) publish(room, current);
} catch (error) {
logger.error("Reconciling voice presence failed", error);
}
return [...presenceOf(room.roomId).userIds];
}
function isWithinGrace(
timestampByRoomUser: Map<string, number>,
roomId: number,
userId: number,
) {
const timestamp = timestampByRoomUser.get(roomUserKey(roomId, userId));
return timestamp !== undefined && Date.now() - timestamp < PRESENCE_GRACE_MS;
}
function pruneGraceEntries() {
for (const timestampByRoomUser of [joinedAtByRoomUser, leftAtByRoomUser]) {
for (const [key, timestamp] of timestampByRoomUser) {
if (Date.now() - timestamp >= PRESENCE_GRACE_MS) {
timestampByRoomUser.delete(key);
}
}
}
}
function roomUserKey(roomId: number, userId: number) {
return `${roomId}:${userId}`;
}
function presenceOf(roomId: number) {
const existing = presenceByRoomId.get(roomId);
if (existing) return existing;
const created: RoomPresence = { userIds: new Set(), reconciledAt: 0 };
presenceByRoomId.set(roomId, created);
return created;
}
function publish(room: PresenceRoom, presence: RoomPresence) {
EventBus.publish(
[...room.participantUserIds.map(userChannel), chatRoomChannel(room.roomId)],
{
kind: "voicePresence",
roomId: room.roomId,
userIds: [...presence.userIds],
},
);
}

View File

@@ -0,0 +1,105 @@
import {
differenceInSeconds,
endOfMonth,
startOfMonth,
subDays,
} from "date-fns";
import * as ChatRoomResolver from "~/features/chat/ChatRoomResolver.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import * as Daily from "../core/Daily.server";
import * as VoiceAvailability from "../core/VoiceAvailability.server";
import * as VoiceCost from "../core/VoiceCost";
import * as VoicePresence from "../core/VoicePresence.server";
import * as VoiceRepository from "../VoiceRepository.server";
import {
VOICE_FREE_MINUTES_PER_MONTH,
VOICE_MONTHLY_BUDGET_USD,
} from "../voice-constants";
import { requireVoiceDashboardAccess } from "../voice-utils.server";
const STATS_DAYS = 14;
export const loader = async () => {
requireVoiceDashboardAccess();
const since = dateToDatabaseTimestamp(subDays(new Date(), STATS_DAYS));
const [
settings,
available,
monthMinutes,
adoption,
playerAdoption,
voiceChatPreferences,
platforms,
leaveReasons,
errorCounts,
latestErrors,
ratings,
feedbackComments,
liveRooms,
] = await Promise.all([
VoiceRepository.findSettings(),
VoiceAvailability.isAvailable(),
VoiceAvailability.monthParticipantMinutes(),
VoiceRepository.findDailyAdoptionSince(since),
VoiceRepository.findPlayerAdoptionSince(since),
VoiceRepository.findVoiceChatPreferenceStatsSince(since),
VoiceRepository.findPlatformStatsSince(since),
VoiceRepository.countLeaveReasonsSince(since),
VoiceRepository.countClientErrorsSince(since),
VoiceRepository.findLatestClientErrors(),
VoiceRepository.countRatingsSince(since),
VoiceRepository.findLatestFeedbackComments(),
liveRoomsWithTypes(),
]);
return {
statsDays: STATS_DAYS,
isConfigured: Daily.isConfigured(),
isDisabled: settings.isDisabled,
available,
cost: {
monthMinutes,
freeMinutes: VOICE_FREE_MINUTES_PER_MONTH,
budgetUsd: VOICE_MONTHLY_BUDGET_USD,
monthCostUsd: VoiceCost.monthlyCostUsd(monthMinutes),
projectedCostUsd: VoiceCost.projectedMonthlyCostUsd({
participantMinutes: monthMinutes,
monthProgress: monthProgress(),
}),
},
liveRooms,
adoption,
playerAdoption,
voiceChatPreferences,
platforms,
leaveReasons,
errorCounts,
latestErrors,
ratings,
feedbackComments,
};
};
async function liveRoomsWithTypes() {
const occupied = VoicePresence.allOccupiedRooms();
const resolved = await ChatRoomResolver.resolveAll(
occupied.map((room) => room.roomId),
);
return occupied.map((room) => ({
...room,
type: resolved.find((candidate) => candidate.roomId === room.roomId)?.type,
}));
}
function monthProgress() {
const now = new Date();
const start = startOfMonth(now);
return (
differenceInSeconds(now, start) /
differenceInSeconds(endOfMonth(now), start)
);
}

View File

@@ -0,0 +1,45 @@
.facts {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--s-1) var(--s-4);
margin: 0;
& dt {
color: var(--color-text-high);
font-weight: var(--weight-semi);
}
& dd {
margin: 0;
}
}
.budgetMeter {
width: 100%;
max-width: 32rem;
height: var(--s-4);
}
.table {
border-collapse: collapse;
font-size: var(--font-sm);
& th,
& td {
padding: var(--s-1) var(--s-3);
text-align: left;
border-bottom: var(--border-style);
white-space: nowrap;
}
& th {
font-weight: var(--weight-semi);
}
}
.listRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
}

View File

@@ -0,0 +1,539 @@
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
import * as R from "remeda";
import { ActionButton } from "~/components/ActionButton";
import { LineChart } from "~/components/LineChart";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { UserLink } from "~/components/UserLink";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/admin.voice.server";
import { loader } from "../loaders/admin.voice.server";
import { VOICE_PLATFORMS } from "../voice-constants";
import { voiceDashboardActionSchema } from "../voice-schemas";
import styles from "./admin.voice.module.css";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Voice beta",
location: args.location,
});
};
type LoaderData = ReturnType<typeof useLoaderData<typeof loader>>;
export default function AdminVoicePage() {
const data = useLoaderData<typeof loader>();
return (
<Main className="stack xl">
<h1>Voice beta</h1>
<Status data={data} />
<Cost cost={data.cost} />
<LiveRooms liveRooms={data.liveRooms} />
<p className="text-lighter text-sm">
The panels below cover the last {data.statsDays} days.
</p>
<PlayerAdoption playerAdoption={data.playerAdoption} />
<Adoption adoption={data.adoption} />
<VoiceChatPreferences voiceChatPreferences={data.voiceChatPreferences} />
<Platforms platforms={data.platforms} />
<LeaveReasons leaveReasons={data.leaveReasons} />
<Errors errorCounts={data.errorCounts} latestErrors={data.latestErrors} />
<Feedback
ratings={data.ratings}
feedbackComments={data.feedbackComments}
/>
</Main>
);
}
function Status({ data }: { data: LoaderData }) {
return (
<section className="stack sm">
<h2>Status</h2>
<dl className={styles.facts}>
<dt>Daily configured</dt>
<dd>{data.isConfigured ? "Yes" : "No"}</dd>
<dt>Kill switch</dt>
<dd>{data.isDisabled ? "On (voice disabled)" : "Off"}</dd>
<dt>Available to users</dt>
<dd>{data.available ? "Yes" : "No"}</dd>
</dl>
<div>
{data.isDisabled ? (
<ActionButton schema={voiceDashboardActionSchema} action="ENABLE">
Enable voice
</ActionButton>
) : (
<ActionButton
schema={voiceDashboardActionSchema}
action="DISABLE"
variant="destructive"
confirm={{
dialogHeading: "Disable voice for everyone?",
description:
"Nobody can join a call until it is enabled again. People already in a call stay until they leave.",
submitButtonText: "Disable",
submitButtonVariant: "destructive",
}}
>
Disable voice
</ActionButton>
)}
</div>
</section>
);
}
function Cost({ cost }: { cost: LoaderData["cost"] }) {
const usedShare = Math.min(1, cost.monthCostUsd / cost.budgetUsd);
return (
<section className="stack sm">
<h2>Cost this month</h2>
<dl className={styles.facts}>
<dt>Participant-minutes</dt>
<dd>
{formatNumber(cost.monthMinutes)} ({formatNumber(cost.freeMinutes)}{" "}
free)
</dd>
<dt>Cost so far</dt>
<dd>{formatUsd(cost.monthCostUsd)}</dd>
<dt>Projected month end</dt>
<dd>{formatUsd(cost.projectedCostUsd)}</dd>
<dt>Budget</dt>
<dd>{formatUsd(cost.budgetUsd)}, voice turns off once reached</dd>
</dl>
<meter
className={styles.budgetMeter}
min={0}
max={1}
low={0.6}
high={0.85}
optimum={0}
value={usedShare}
aria-label="Share of the monthly budget used"
/>
</section>
);
}
function LiveRooms({ liveRooms }: { liveRooms: LoaderData["liveRooms"] }) {
return (
<section className="stack sm">
<h2>Live now</h2>
{liveRooms.length === 0 ? (
<p className="text-lighter">Nobody is in a voice call.</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>Room</th>
<th>Type</th>
<th>People</th>
</tr>
</thead>
<tbody>
{liveRooms.map((room) => (
<tr key={room.roomId}>
<td>{room.roomId}</td>
<td>{room.type ?? "?"}</td>
<td>{room.userIds.length}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
function Adoption({ adoption }: { adoption: LoaderData["adoption"] }) {
const byType = R.groupBy(adoption, (row) => row.type);
const series = Object.entries(byType).map(([type, rows]) => ({
label: type,
points: rows.map((row) => ({
x: new Date(`${row.day}T00:00:00Z`).getTime(),
y: Math.round((row.voiceRoomCount / row.roomCount) * 100),
})),
}));
const canChart = series.some((entry) => entry.points.length >= 2);
return (
<section className="stack sm">
<h2>Room adoption</h2>
<p className="text-lighter text-sm">
Share of new rooms where at least one person connected to voice.
</p>
{canChart ? (
<LineChart
series={series}
xAxis={{ type: "date" }}
formatValue={(value) => `${value}%`}
interactive
ariaLabel="Share of rooms with voice per day"
/>
) : null}
<table className={styles.table}>
<thead>
<tr>
<th>Day</th>
<th>Type</th>
<th>Rooms</th>
<th>With voice</th>
<th>2+ connected</th>
</tr>
</thead>
<tbody>
{adoption.map((row) => (
<tr key={`${row.day}-${row.type}`}>
<td>{row.day}</td>
<td>{row.type}</td>
<td>{row.roomCount}</td>
<td>
{row.voiceRoomCount} (
{percent(row.voiceRoomCount, row.roomCount)})
</td>
<td>
{row.conversationRoomCount} (
{percent(row.conversationRoomCount, row.roomCount)})
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function PlayerAdoption({
playerAdoption,
}: {
playerAdoption: LoaderData["playerAdoption"];
}) {
const willing = playerAdoption.filter((row) => row.vc !== "NO");
const series = Object.entries(R.groupBy(willing, (row) => row.roomType)).map(
([type, rows]) => ({
label: type,
points: Object.entries(R.groupBy(rows, (row) => row.day)).map(
([day, dayRows]) => ({
x: new Date(`${day}T00:00:00Z`).getTime(),
y: Math.round(
(R.sumBy(dayRows, (row) => row.connectedCount) /
R.sumBy(dayRows, (row) => row.playerCount)) *
100,
),
}),
),
}),
);
const canChart = series.some((entry) => entry.points.length >= 2);
const totals = Object.values(
R.groupBy(playerAdoption, (row) => `${row.roomType}-${row.vc}`),
).map((rows) => ({
roomType: rows[0].roomType,
vc: rows[0].vc,
playerCount: R.sumBy(rows, (row) => row.playerCount),
connectedCount: R.sumBy(rows, (row) => row.connectedCount),
}));
return (
<section className="stack sm">
<h2>Player adoption</h2>
<p className="text-lighter text-sm">
Share of players in new rooms who connected to voice, counting only
those whose voice chat setting is "yes" or "listen only". Uses the
players' current setting, which may have changed since the match.
</p>
{canChart ? (
<LineChart
series={series}
xAxis={{ type: "date" }}
formatValue={(value) => `${value}%`}
interactive
ariaLabel="Share of willing players who connected to voice per day"
/>
) : null}
<table className={styles.table}>
<thead>
<tr>
<th>Type</th>
<th>Voice chat setting</th>
<th>Players</th>
<th>Connected</th>
</tr>
</thead>
<tbody>
{R.sortBy(
totals,
(row) => row.roomType,
(row) => row.vc,
).map((row) => (
<tr key={`${row.roomType}-${row.vc}`}>
<td>{row.roomType}</td>
<td>{row.vc}</td>
<td>{row.playerCount}</td>
<td>
{row.connectedCount} (
{percent(row.connectedCount, row.playerCount)})
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function VoiceChatPreferences({
voiceChatPreferences,
}: {
voiceChatPreferences: LoaderData["voiceChatPreferences"];
}) {
return (
<section className="stack sm">
<h2>Joins by voice chat setting</h2>
<p className="text-lighter text-sm">
The joiner's own setting at the time they joined.
</p>
<table className={styles.table}>
<thead>
<tr>
<th>Setting</th>
<th>Joins</th>
<th>Connected</th>
<th>Users</th>
<th>Turned mic on</th>
<th>Mic-on time</th>
</tr>
</thead>
<tbody>
{voiceChatPreferences.map((row) => (
<tr key={row.vcPreference}>
<td>{row.vcPreference}</td>
<td>{row.joinCount}</td>
<td>{percent(row.connectedCount, row.joinCount)}</td>
<td>{row.uniqueUserCount}</td>
<td>{percent(row.talkedCount, row.connectedCount)}</td>
<td>{formatMinutes(row.talkSeconds)}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function Platforms({ platforms }: { platforms: LoaderData["platforms"] }) {
return (
<section className="stack sm">
<h2>Join funnel and quality</h2>
<div className="overflow-x-auto">
<table className={styles.table}>
<thead>
<tr>
<th>Platform</th>
<th>Joins</th>
<th>Connected</th>
<th>Stayed &gt; 1 min</th>
<th>Users</th>
<th>Avg length</th>
<th>Low quality</th>
<th>Bad network</th>
<th>Push-to-talk</th>
</tr>
</thead>
<tbody>
{platforms.map((row) => (
<tr key={row.platform}>
<td>{row.platform}</td>
<td>{row.joinCount}</td>
<td>{percent(row.connectedCount, row.joinCount)}</td>
<td>{percent(row.stayedCount, row.joinCount)}</td>
<td>{row.uniqueUserCount}</td>
<td>{formatMinutes(row.averageSeconds)}</td>
<td>{formatMinutes(row.lowQualitySeconds)}</td>
<td>{percent(row.badNetworkCount, row.connectedCount)}</td>
<td>{percent(row.pushToTalkCount, row.connectedCount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function LeaveReasons({
leaveReasons,
}: {
leaveReasons: LoaderData["leaveReasons"];
}) {
const reasons = R.unique(leaveReasons.map((row) => row.leaveReason));
const countOf = (reason: string | null, platform: string) =>
leaveReasons.find(
(row) => row.leaveReason === reason && row.platform === platform,
)?.count ?? 0;
return (
<section className="stack sm">
<h2>Leave reasons</h2>
<table className={styles.table}>
<thead>
<tr>
<th>Reason</th>
{VOICE_PLATFORMS.map((platform) => (
<th key={platform}>{platform}</th>
))}
</tr>
</thead>
<tbody>
{reasons.map((reason) => (
<tr key={reason}>
<td>{reason}</td>
{VOICE_PLATFORMS.map((platform) => (
<td key={platform}>{countOf(reason, platform)}</td>
))}
</tr>
))}
</tbody>
</table>
</section>
);
}
function Errors({
errorCounts,
latestErrors,
}: {
errorCounts: LoaderData["errorCounts"];
latestErrors: LoaderData["latestErrors"];
}) {
return (
<section className="stack sm">
<h2>Client errors</h2>
<table className={styles.table}>
<thead>
<tr>
<th>Kind</th>
<th>Platform</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{errorCounts.map((row) => (
<tr key={`${row.kind}-${row.platform}`}>
<td>{row.kind}</td>
<td>{row.platform}</td>
<td>{row.count}</td>
</tr>
))}
</tbody>
</table>
<h3>Latest</h3>
<ul className="stack sm">
{latestErrors.map((error) => (
<li key={error.id} className={styles.listRow}>
<UserLink user={error.user} />
<span>
{error.kind} · {error.platform}
</span>
<span className="text-lighter text-xs">
<LocaleTime
date={error.createdAt}
inline
options={{
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
/>
</span>
{error.detail ? (
<span className="text-lighter text-xs">{error.detail}</span>
) : null}
</li>
))}
</ul>
</section>
);
}
function Feedback({
ratings,
feedbackComments,
}: {
ratings: LoaderData["ratings"];
feedbackComments: LoaderData["feedbackComments"];
}) {
const total = R.sumBy(ratings, (row) => row.count);
const average =
total === 0
? null
: R.sumBy(ratings, (row) => row.rating * row.count) / total;
return (
<section className="stack sm">
<h2>Feedback</h2>
<p>
{total} answers
{average === null ? null : `, average ${average.toFixed(2)} / 5`}
</p>
<table className={styles.table}>
<thead>
<tr>
<th>Rating</th>
<th>Answers</th>
</tr>
</thead>
<tbody>
{ratings.map((row) => (
<tr key={row.rating}>
<td>{row.rating}</td>
<td>{row.count}</td>
</tr>
))}
</tbody>
</table>
<h3>Latest comments</h3>
<ul className="stack sm">
{feedbackComments.map((feedback) => (
<li key={feedback.id} className="stack xs">
<div className={styles.listRow}>
<UserLink user={feedback.user} />
<span>
{feedback.rating} / 5 · {feedback.roomType} ·{" "}
{feedback.platform}
</span>
</div>
<p className="whitespace-pre-wrap">{feedback.comment}</p>
</li>
))}
</ul>
</section>
);
}
function percent(count: number, total: number) {
if (total === 0) return "-";
return `${Math.round((count / total) * 100)}%`;
}
function formatMinutes(seconds: number | null) {
if (seconds === null) return "-";
return `${(seconds / 60).toFixed(1)} min`;
}
function formatNumber(value: number) {
return String(Math.round(value));
}
function formatUsd(value: number) {
return `$${value.toFixed(2)}`;
}

View File

@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as ChatRoomFactory from "~/db/seed/factories/ChatRoomFactory";
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as VoiceSessionFactory from "~/db/seed/factories/VoiceSessionFactory";
import { wrappedAction } from "~/utils/Test";
import * as VoicePresence from "../core/VoicePresence.server";
import type { voiceRoomActionSchema } from "../voice-schemas";
import { action } from "./api.voice.rooms.$id";
const voiceRoomAction = wrappedAction<typeof voiceRoomActionSchema>({
action,
});
const users = UserFactory.pool();
const playerId = () => users.id(1);
beforeEach(async () => {
await users.create(8);
});
describe("Voice room action", () => {
test("CONNECTED with a session of another room is refused and adds no presence", async () => {
const matchRoomId = await createMatchRoom();
const otherRoom = await ChatRoomFactory.create();
const session = await VoiceSessionFactory.create({
userId: playerId(),
roomId: otherRoom.id,
});
await expect(connected(matchRoomId, session.id)).rejects.toThrow("403");
expect(presentUserIds(matchRoomId)).not.toContain(playerId());
});
test("CONNECTED arriving after leaving doesn't put the user back in presence", async () => {
const matchRoomId = await createMatchRoom();
const session = await VoiceSessionFactory.create({
userId: playerId(),
roomId: matchRoomId,
});
await voiceRoomAction(
{ _action: "LEAVE", voiceSessionId: session.id, leaveReason: "LEFT" },
{ user: playerId(), params: { id: String(matchRoomId) } },
);
await expect(connected(matchRoomId, session.id)).rejects.toThrow("403");
expect(presentUserIds(matchRoomId)).not.toContain(playerId());
});
test("LEAVE with a session of another room is refused", async () => {
const matchRoomId = await createMatchRoom();
const otherRoom = await ChatRoomFactory.create();
const session = await VoiceSessionFactory.create({
userId: playerId(),
roomId: otherRoom.id,
});
await expect(
voiceRoomAction(
{ _action: "LEAVE", voiceSessionId: session.id, leaveReason: "LEFT" },
{ user: playerId(), params: { id: String(matchRoomId) } },
),
).rejects.toThrow("403");
});
});
async function createMatchRoom() {
const match = await SQMatchFactory.create({
alphaUserIds: users.ids(4),
bravoUserIds: users.ids().slice(4),
});
return match.chatRoomId!;
}
function connected(roomId: number, voiceSessionId: number) {
return voiceRoomAction(
{ _action: "CONNECTED", voiceSessionId, dailySessionId: "daily-1" },
{ user: playerId(), params: { id: String(roomId) } },
);
}
function presentUserIds(roomId: number) {
return (
VoicePresence.allOccupiedRooms().find((room) => room.roomId === roomId)
?.userIds ?? []
);
}

View File

@@ -0,0 +1,186 @@
import { addMinutes, subHours } from "date-fns";
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as ChatRoomResolver from "~/features/chat/ChatRoomResolver.server";
import { defineAction } from "~/form/define-action.server";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import {
badRequestIfFalsy,
forbidden,
notFoundIfNullish,
parseParams,
} from "~/utils/remix.server";
import { idObject } from "~/utils/schema";
import * as Daily from "../core/Daily.server";
import * as VoiceAccess from "../core/VoiceAccess";
import * as VoiceAvailability from "../core/VoiceAvailability.server";
import * as VoicePresence from "../core/VoicePresence.server";
import * as VoiceRepository from "../VoiceRepository.server";
import {
VOICE_FEEDBACK_COOLDOWN_HOURS,
VOICE_FEEDBACK_MIN_SESSION_SECONDS,
VOICE_TOKEN_LIFETIME_MINUTES,
} from "../voice-constants";
import { voiceRoomActionSchema } from "../voice-schemas";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = requireUser();
const { id: roomId } = parseParams({ params, schema: idObject });
const room = await ChatRoomResolver.requireRoom(roomId, "VIEW");
const available =
VoiceAccess.isVoiceRoomType(room.type) &&
(await VoiceAvailability.isAvailable());
return {
available,
canJoin:
available &&
VoiceAccess.canJoin(room, user.id) &&
!(await VoiceRepository.hasBeenKicked({ roomId, userId: user.id })),
canKick: available && VoiceAccess.canKick(room, user.id),
userIds: available ? await VoicePresence.reconciledUserIdsOf(room) : [],
};
};
export const action = defineAction(
{
params: idObject,
body: voiceRoomActionSchema,
onInvalidBody: "badRequest",
},
async ({ params: { id: roomId }, body: data }) => {
const user = requireUser();
const room = await ChatRoomResolver.requireRoom(roomId, "VIEW");
switch (data._action) {
case "JOIN": {
if (
!VoiceAccess.canJoin(room, user.id) ||
userIsBanned(user.id) ||
(await VoiceRepository.hasBeenKicked({ roomId, userId: user.id }))
) {
forbidden();
}
badRequestIfFalsy(await VoiceAvailability.isAvailable());
const tokenExpiresAt = Math.min(
dateToDatabaseTimestamp(
addMinutes(new Date(), VOICE_TOKEN_LIFETIME_MINUTES),
),
room.expiresAt,
);
await Daily.ensureRoom({
chatRoomId: roomId,
expiresAt: room.expiresAt,
});
const token = await Daily.createMeetingToken({
chatRoomId: roomId,
userId: user.id,
userName: user.username,
expiresAt: tokenExpiresAt,
});
const voiceChatPreferences =
await VoiceRepository.findVoiceChatPreferences(
R.unique([...room.participantUserIds, user.id]),
);
const voiceSessionId = await VoiceRepository.insertOwnSession({
roomId,
roomType: room.type,
platform: data.platform,
eligibleMemberCount: voiceChatPreferences.filter(
(preference) =>
preference.vc !== "NO" &&
room.participantUserIds.includes(preference.id),
).length,
vcPreference:
voiceChatPreferences.find((preference) => preference.id === user.id)
?.vc ?? "YES",
});
return {
roomUrl: Daily.roomUrl(roomId),
token,
voiceSessionId,
};
}
case "CONNECTED": {
const session = notFoundIfNullish(
await VoiceRepository.findOwnSessionById(data.voiceSessionId),
);
if (
session.roomId !== roomId ||
session.leftAt !== null ||
!VoiceAccess.canJoin(room, user.id)
) {
forbidden();
}
await VoiceRepository.markOwnSessionConnected({
id: data.voiceSessionId,
dailySessionId: data.dailySessionId,
});
VoicePresence.add(room, user.id);
return null;
}
case "LEAVE": {
const session = notFoundIfNullish(
await VoiceRepository.findOwnSessionById(data.voiceSessionId),
);
if (session.roomId !== roomId) forbidden();
await VoiceRepository.markOwnSessionLeft({
id: session.id,
leaveReason: data.leaveReason,
});
VoicePresence.remove(room, [user.id]);
return { askFeedback: await shouldAskFeedback(user.id, session) };
}
case "KICK": {
if (!VoiceAccess.canKick(room, user.id)) forbidden();
badRequestIfFalsy(data.userId !== user.id);
// before ejecting, or the ejected client's own leave report wins the race
await VoiceRepository.markSessionsKicked({
roomId,
userId: data.userId,
});
await Daily.ejectUsers({
chatRoomId: roomId,
userIds: [data.userId],
ban: true,
});
VoicePresence.remove(room, [data.userId]);
return null;
}
}
},
);
async function shouldAskFeedback(
userId: number,
session: { connectedAt: number | null },
) {
if (session.connectedAt === null) return false;
if (
databaseTimestampNow() - session.connectedAt <
VOICE_FEEDBACK_MIN_SESSION_SECONDS
) {
return false;
}
return !(await VoiceRepository.hasFeedbackSince({
userId,
since: dateToDatabaseTimestamp(
subHours(new Date(), VOICE_FEEDBACK_COOLDOWN_HOURS),
),
}));
}

View File

@@ -0,0 +1,56 @@
import { requireUser } from "~/features/auth/core/user.server";
import { defineAction } from "~/form/define-action.server";
import { notFoundIfNullish } from "~/utils/remix.server";
import * as VoiceRepository from "../VoiceRepository.server";
import { voiceTelemetrySchema } from "../voice-schemas";
export const action = defineAction(
{ body: voiceTelemetrySchema, onInvalidBody: "badRequest" },
async ({ body: data }) => {
requireUser();
switch (data.kind) {
case "SUMMARY": {
notFoundIfNullish(
await VoiceRepository.findOwnSessionById(data.voiceSessionId),
);
await VoiceRepository.updateOwnSessionSummary({
id: data.voiceSessionId,
lowQualitySeconds: data.lowQualitySeconds,
talkSeconds: data.talkSeconds,
inputMode: data.inputMode,
});
break;
}
case "ERROR": {
const session =
data.voiceSessionId === null
? undefined
: await VoiceRepository.findOwnSessionById(data.voiceSessionId);
await VoiceRepository.insertOwnClientError({
voiceSessionId: session?.id ?? null,
kind: data.errorKind,
detail: data.detail,
platform: data.platform,
});
break;
}
case "FEEDBACK": {
notFoundIfNullish(
await VoiceRepository.findOwnSessionById(data.voiceSessionId),
);
await VoiceRepository.insertOwnFeedback({
voiceSessionId: data.voiceSessionId,
rating: data.rating,
comment: data.comment || null,
});
break;
}
}
return null;
},
);

View File

@@ -0,0 +1,89 @@
import * as v from "valibot";
import { ServerConfig } from "~/config.server";
import * as ChatRoomResolver from "~/features/chat/ChatRoomResolver.server";
import { defineAction } from "~/form/define-action.server";
import { logger } from "~/utils/logger";
import * as Daily from "../core/Daily.server";
import * as VoicePresence from "../core/VoicePresence.server";
import * as VoiceRepository from "../VoiceRepository.server";
import { VOICE_NETWORK_QUALITY_STATES } from "../voice-constants";
const participantSchema = v.object({
room: v.string(),
user_id: v.nullish(v.string()),
session_id: v.string(),
});
const eventSchema = v.variant("type", [
v.object({
type: v.literal("participant.joined"),
payload: participantSchema,
}),
v.object({
type: v.literal("participant.left"),
payload: v.object({
...participantSchema.entries,
joined_at: v.number(),
duration: v.number(),
networkQualityState: v.optional(
v.fallback(v.picklist(VOICE_NETWORK_QUALITY_STATES), "unknown"),
),
}),
}),
]);
export const action = defineAction(async ({ request }) => {
// biome-ignore lint/plugin: the signature covers the raw body bytes, which a parsed body can't reproduce
const rawBody = await request.text();
const secret = ServerConfig.daily.webhookSecret;
// Daily checks a new webhook endpoint with `{"test":"test"}` and needs a 200
const parsed = v.safeParse(eventSchema, safeJsonParse(rawBody));
if (!parsed.success || !secret) return null;
const isSigned = Daily.isValidWebhookSignature({
rawBody,
timestamp: request.headers.get("X-Webhook-Timestamp") ?? "",
signature: request.headers.get("X-Webhook-Signature") ?? "",
secret,
});
if (!isSigned) {
throw new Response(null, { status: 401 });
}
const event = parsed.output;
const chatRoomId = Daily.chatRoomIdFromRoomName(event.payload.room);
const userId = Number(event.payload.user_id);
if (chatRoomId === null || !Number.isInteger(userId)) return null;
if (event.type === "participant.left") {
await VoiceRepository.updateFromLeftWebhook({
dailySessionId: event.payload.session_id,
durationSeconds: Math.round(event.payload.duration),
networkQualityState: event.payload.networkQualityState ?? null,
leftAt: Math.round(event.payload.joined_at + event.payload.duration),
});
}
const room = await ChatRoomResolver.resolve(chatRoomId);
if (!room) {
logger.warn(`Voice webhook for unknown chat room ${chatRoomId}`);
return null;
}
if (event.type === "participant.joined") {
VoicePresence.add(room, userId);
} else {
VoicePresence.remove(room, [userId]);
}
return null;
});
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}

View File

@@ -0,0 +1,389 @@
import { describe, expect, test, vi } from "vitest";
import type { ServerEvent } from "~/features/events/events-types";
import {
createVoiceClient,
type VoiceCallParticipant,
type VoiceRoomState,
} from "./voice-client";
const ROOM_ID = 1;
const OTHER_ROOM_ID = 2;
const roomState = (
overrides: Partial<VoiceRoomState> = {},
): VoiceRoomState => ({
available: true,
canJoin: true,
canKick: false,
userIds: [],
...overrides,
});
const joinResult = (voiceSessionId: number) => ({
roomUrl: "https://sendou.daily.co/chat-1",
token: `token-${voiceSessionId}`,
voiceSessionId,
});
const participant = (
sessionId: string,
overrides: Partial<VoiceCallParticipant> = {},
): VoiceCallParticipant => ({
userId: 5,
sessionId,
isLocal: false,
audioEnabled: true,
...overrides,
});
function pendingJoins() {
const resolvers: Array<(result: unknown) => void> = [];
let sentCount = 0;
return {
postRoomAction: (_roomId: number, body: object) => {
if (!("_action" in body) || body._action !== "JOIN") {
return Promise.resolve(null);
}
sentCount++;
return new Promise<unknown>((resolve) => resolvers.push(resolve));
},
sentCount: () => sentCount,
sent: (count: number) => vi.waitFor(() => expect(sentCount).toBe(count)),
resolveNext: async (result: unknown) => {
resolvers.shift()!(result);
await new Promise((resolve) => setTimeout(resolve));
},
};
}
function setup({
postRoomAction = vi.fn(async (_roomId: number, body: object) =>
"_action" in body && body._action === "JOIN" ? joinResult(10) : null,
),
}: {
postRoomAction?: (roomId: number, body: object) => Promise<unknown>;
} = {}) {
const serverListeners = new Set<(event: ServerEvent) => void>();
const deps = {
fetchRoom: vi.fn(async () => roomState()),
postRoomAction: vi.fn(postRoomAction),
beaconRoomAction: vi.fn(),
postTelemetry: vi.fn(async () => {}),
beaconTelemetry: vi.fn(),
addServerEventListener: (listener: (event: ServerEvent) => void) => {
serverListeners.add(listener);
return () => serverListeners.delete(listener);
},
platform: () => "DESKTOP" as const,
playSound: vi.fn(),
};
const client = createVoiceClient(deps);
client.start();
const emit = (event: ServerEvent) => {
for (const listener of serverListeners) listener(event);
};
return { client, deps, emit };
}
describe("createVoiceClient", () => {
test("applies presence events to loaded rooms", async () => {
const { client, emit } = setup();
await client.loadRoom(ROOM_ID);
emit({ kind: "voicePresence", roomId: ROOM_ID, userIds: [5, 6] });
expect(client.getSnapshot().roomsById.get(ROOM_ID)?.userIds).toEqual([
5, 6,
]);
});
test("ignores presence events of rooms it never loaded", () => {
const { client, emit } = setup();
emit({ kind: "voicePresence", roomId: ROOM_ID, userIds: [5] });
expect(client.getSnapshot().roomsById.size).toBe(0);
});
test("joining hands the granted credentials to the call", async () => {
const { client, deps } = setup();
await client.join(ROOM_ID);
expect(deps.postRoomAction).toHaveBeenCalledWith(ROOM_ID, {
_action: "JOIN",
platform: "DESKTOP",
});
expect(client.getSnapshot().call).toMatchObject({
roomId: ROOM_ID,
voiceSessionId: 10,
status: "JOINING",
credentials: { token: "token-10" },
});
});
test("a refused join fails the call and reports it", async () => {
const { client, deps } = setup({ postRoomAction: async () => null });
await client.join(ROOM_ID);
expect(client.getSnapshot().call?.status).toBe("FAILED");
expect(deps.postTelemetry).toHaveBeenCalledWith(
expect.objectContaining({ kind: "ERROR", errorKind: "JOIN_FAILED" }),
);
});
test("joining another room leaves the current call first", async () => {
const { client, deps } = setup();
await client.join(ROOM_ID);
await client.join(OTHER_ROOM_ID);
expect(deps.postRoomAction).toHaveBeenCalledWith(ROOM_ID, {
_action: "LEAVE",
voiceSessionId: 10,
leaveReason: "LEFT",
});
expect(client.getSnapshot().call?.roomId).toBe(OTHER_ROOM_ID);
});
test("leaving asks for feedback when the server wants it, then sends the answer", async () => {
const { client, deps } = setup({
postRoomAction: async (_roomId, body) =>
"_action" in body && body._action === "JOIN"
? joinResult(10)
: { askFeedback: true },
});
await client.join(ROOM_ID);
await client.leave();
expect(client.getSnapshot().pendingFeedback).toEqual({
roomId: ROOM_ID,
voiceSessionId: 10,
});
await client.submitFeedback({ rating: 4, comment: null });
expect(deps.postTelemetry).toHaveBeenCalledWith({
kind: "FEEDBACK",
voiceSessionId: 10,
rating: 4,
comment: null,
});
expect(client.getSnapshot().pendingFeedback).toBeNull();
});
test("leaving while the join is in flight ends the session the server started", async () => {
const joins = pendingJoins();
const { client, deps } = setup({ postRoomAction: joins.postRoomAction });
void client.join(ROOM_ID);
await joins.sent(1);
await client.leave();
await joins.resolveNext(joinResult(10));
expect(deps.postRoomAction).toHaveBeenCalledWith(ROOM_ID, {
_action: "LEAVE",
voiceSessionId: 10,
leaveReason: "LEFT",
});
expect(client.getSnapshot().call).toBeNull();
});
test("rejoining while the earlier join is in flight waits for it and keeps only its own session", async () => {
const joins = pendingJoins();
const { client, deps } = setup({ postRoomAction: joins.postRoomAction });
void client.join(ROOM_ID);
await joins.sent(1);
await client.leave();
const rejoin = client.join(ROOM_ID);
await new Promise((resolve) => setTimeout(resolve));
expect(joins.sentCount()).toBe(1);
await joins.resolveNext(joinResult(10));
await joins.sent(2);
await joins.resolveNext(joinResult(11));
await rejoin;
expect(deps.postRoomAction).toHaveBeenCalledWith(ROOM_ID, {
_action: "LEAVE",
voiceSessionId: 10,
leaveReason: "LEFT",
});
expect(client.getSnapshot().call).toMatchObject({
voiceSessionId: 11,
credentials: { token: "token-11" },
});
});
test("closing the page reports the leave and the latest totals by beacon", async () => {
const { client, deps } = setup();
await client.join(ROOM_ID);
client.setSummary({ lowQualitySeconds: 3, talkSeconds: 40 });
client.leaveOnPageClose();
expect(deps.beaconRoomAction).toHaveBeenCalledWith(ROOM_ID, {
_action: "LEAVE",
voiceSessionId: 10,
leaveReason: "PAGE_CLOSED",
});
expect(deps.beaconTelemetry).toHaveBeenCalledWith({
kind: "SUMMARY",
voiceSessionId: 10,
inputMode: "OPEN_MIC",
lowQualitySeconds: 3,
talkSeconds: 40,
});
});
test("leaving reports the leave even when the call object never finishes leaving", async () => {
const { client, deps } = setup();
await client.join(ROOM_ID);
client.registerCallHandle({
setLocalAudio: () => {},
leave: () => new Promise(() => {}),
resumeAudio: () => {},
});
await client.leave();
expect(deps.postRoomAction).toHaveBeenCalledWith(ROOM_ID, {
_action: "LEAVE",
voiceSessionId: 10,
leaveReason: "LEFT",
});
});
test("the microphone follows the controls through the call handle", async () => {
const { client } = setup();
const setLocalAudio = vi.fn();
await client.join(ROOM_ID);
client.registerCallHandle({
setLocalAudio,
leave: async () => {},
resumeAudio: () => {},
});
client.setMicEnabled(true);
expect(setLocalAudio).toHaveBeenCalledWith(true);
expect(client.getSnapshot().call?.micEnabled).toBe(true);
});
test("deafening mutes the microphone and undeafening restores it", async () => {
const { client } = setup();
const setLocalAudio = vi.fn();
await client.join(ROOM_ID);
client.registerCallHandle({
setLocalAudio,
leave: async () => {},
resumeAudio: () => {},
});
client.setMicEnabled(true);
client.setDeafened(true);
expect(setLocalAudio).toHaveBeenLastCalledWith(false);
expect(client.getSnapshot().call).toMatchObject({
deafened: true,
micEnabled: false,
});
client.setDeafened(false);
expect(setLocalAudio).toHaveBeenLastCalledWith(true);
expect(client.getSnapshot().call).toMatchObject({
deafened: false,
micEnabled: true,
});
});
test("a participant who mutes mid-sentence stops speaking", async () => {
const { client } = setup();
await client.join(ROOM_ID);
client.updateParticipants([participant("remote", { audioEnabled: true })]);
client.setSpeaking("remote", true);
client.updateParticipants([participant("remote", { audioEnabled: false })]);
client.setSpeaking("remote", true);
expect(client.getSnapshot().call?.speakingSessionIds).toEqual([]);
});
test("muting the own microphone clears the own speaking indicator right away", async () => {
const { client } = setup();
await client.join(ROOM_ID);
client.updateParticipants([
participant("local", { audioEnabled: true, isLocal: true }),
]);
client.setMicEnabled(true);
client.setSpeaking("local", true);
client.setMicEnabled(false);
expect(client.getSnapshot().call?.speakingSessionIds).toEqual([]);
});
test("unmuting the microphone undeafens", async () => {
const { client } = setup();
await client.join(ROOM_ID);
client.setDeafened(true);
client.setMicEnabled(true);
expect(client.getSnapshot().call).toMatchObject({
deafened: false,
micEnabled: true,
});
});
test("connecting plays the join sound once, not for everyone already in the call", async () => {
const { client, deps } = setup();
await client.join(ROOM_ID);
client.updateParticipants([
participant("local", { isLocal: true }),
participant("remote-1"),
participant("remote-2"),
]);
client.markConnected("local");
expect(deps.playSound).toHaveBeenCalledTimes(1);
expect(deps.playSound).toHaveBeenCalledWith("voice_join");
});
test("others joining and leaving the call play their sounds", async () => {
const { client, deps } = setup();
await client.join(ROOM_ID);
client.updateParticipants([participant("local", { isLocal: true })]);
client.markConnected("local");
deps.playSound.mockClear();
client.updateParticipants([
participant("local", { isLocal: true }),
participant("remote"),
]);
client.updateParticipants([participant("local", { isLocal: true })]);
expect(deps.playSound.mock.calls).toEqual([
["voice_join"],
["voice_leave"],
]);
});
test("leaving a connected call plays the leave sound, a call that never connected doesn't", async () => {
const { client, deps } = setup();
await client.join(ROOM_ID);
await client.leave();
expect(deps.playSound).not.toHaveBeenCalled();
await client.join(ROOM_ID);
client.markConnected("local");
deps.playSound.mockClear();
await client.leave();
expect(deps.playSound).toHaveBeenCalledWith("voice_leave");
});
});

View File

@@ -0,0 +1,524 @@
import type { ServerEvent } from "~/features/events/events-types";
import { logger } from "~/utils/logger";
import { VOICE_TELEMETRY_ROUTE, voiceRoomDataRoute } from "~/utils/urls";
import * as Sounds from "../chat/core/Sounds";
import { eventsClient } from "../events/events-client";
import {
VOICE_JOIN_SOUND,
VOICE_LEAVE_SOUND,
type VoiceClientErrorKind,
type VoiceInputMode,
type VoiceLeaveReason,
type VoicePlatform,
} from "./voice-constants";
export interface VoiceRoomState {
available: boolean;
canJoin: boolean;
canKick: boolean;
userIds: number[];
}
export type VoiceCallStatus = "JOINING" | "CONNECTED" | "FAILED";
export interface VoiceCallParticipant {
userId: number;
sessionId: string;
isLocal: boolean;
audioEnabled: boolean;
}
export interface VoiceCall {
roomId: number;
voiceSessionId: number | null;
/** Set once the server granted the join, the call connects with these. */
credentials: { roomUrl: string; token: string } | null;
status: VoiceCallStatus;
micEnabled: boolean;
deafened: boolean;
inputMode: VoiceInputMode;
participants: VoiceCallParticipant[];
speakingSessionIds: string[];
audioBlocked: boolean;
errorKind: VoiceClientErrorKind | null;
}
export interface VoiceSnapshot {
roomsById: ReadonlyMap<number, VoiceRoomState>;
call: VoiceCall | null;
volumeByUserId: ReadonlyMap<number, number>;
pendingFeedback: { roomId: number; voiceSessionId: number } | null;
}
export interface VoiceCallHandle {
setLocalAudio: (enabled: boolean) => void;
leave: () => Promise<void>;
resumeAudio: () => void;
}
export interface VoiceTelemetrySummary {
lowQualitySeconds: number;
talkSeconds: number;
}
interface VoiceClientDeps {
fetchRoom: (roomId: number) => Promise<VoiceRoomState | null>;
postRoomAction: (roomId: number, body: object) => Promise<unknown>;
beaconRoomAction: (roomId: number, body: object) => void;
postTelemetry: (body: object) => Promise<void>;
beaconTelemetry: (body: object) => void;
addServerEventListener: (
listener: (event: ServerEvent) => void,
) => () => void;
platform: () => VoicePlatform;
playSound: (soundCode: string) => void;
}
export interface VoiceClient {
getSnapshot: () => VoiceSnapshot;
subscribe: (listener: () => void) => () => void;
start: () => () => void;
loadRoom: (roomId: number) => Promise<void>;
join: (roomId: number) => Promise<void>;
leave: (reason?: VoiceLeaveReason) => Promise<void>;
leaveOnPageClose: () => void;
kick: (roomId: number, userId: number) => Promise<void>;
setMicEnabled: (enabled: boolean) => void;
setDeafened: (deafened: boolean) => void;
setInputMode: (mode: VoiceInputMode) => void;
setVolume: (userId: number, volume: number) => void;
registerCallHandle: (handle: VoiceCallHandle | null) => void;
markConnected: (dailySessionId: string) => void;
markFailed: (kind: VoiceClientErrorKind | null, detail: string) => void;
updateParticipants: (participants: VoiceCallParticipant[]) => void;
setSpeaking: (sessionId: string, speaking: boolean) => void;
setAudioBlocked: (blocked: boolean) => void;
flagError: (kind: VoiceClientErrorKind, detail: string | null) => void;
resumeAudio: () => void;
setSummary: (summary: VoiceTelemetrySummary) => void;
flushSummary: () => void;
submitFeedback: (feedback: {
rating: number;
comment: string | null;
}) => Promise<void>;
dismissFeedback: () => void;
}
export function createVoiceClient(deps: VoiceClientDeps): VoiceClient {
let snapshot: VoiceSnapshot = {
roomsById: new Map(),
call: null,
volumeByUserId: new Map(),
pendingFeedback: null,
};
let callHandle: VoiceCallHandle | null = null;
let latestSummary: VoiceTelemetrySummary | null = null;
let preferredInputMode: VoiceInputMode = "OPEN_MIC";
let micEnabledBeforeDeafen = false;
let joinAttempt = 0;
let latestJoinRequest: Promise<unknown> = Promise.resolve();
const listeners = new Set<() => void>();
const update = (patch: Partial<VoiceSnapshot>) => {
snapshot = { ...snapshot, ...patch };
for (const listener of listeners) listener();
};
const updateCall = (patch: Partial<VoiceCall>) => {
if (!snapshot.call) return;
update({ call: { ...snapshot.call, ...patch } });
};
const setRoom = (roomId: number, room: VoiceRoomState) => {
const roomsById = new Map(snapshot.roomsById);
roomsById.set(roomId, room);
update({ roomsById });
};
const sendSummary = (viaBeacon: boolean) => {
const call = snapshot.call;
if (!call?.voiceSessionId || !latestSummary) return;
const body = {
kind: "SUMMARY",
voiceSessionId: call.voiceSessionId,
inputMode: call.inputMode,
...latestSummary,
};
if (viaBeacon) {
deps.beaconTelemetry(body);
} else {
void deps.postTelemetry(body);
}
};
const reportError = (kind: VoiceClientErrorKind, detail: string | null) => {
void deps.postTelemetry({
kind: "ERROR",
voiceSessionId: snapshot.call?.voiceSessionId ?? null,
errorKind: kind,
detail: detail?.slice(0, 500) ?? null,
platform: deps.platform(),
});
};
const leave = async (reason: VoiceLeaveReason = "LEFT") => {
const call = snapshot.call;
if (!call) return;
sendSummary(false);
if (call.status === "CONNECTED") deps.playSound(VOICE_LEAVE_SOUND);
// not awaited: clearing the call unmounts its call object, whose leave then never settles
void callHandle?.leave().catch((error) => {
logger.error("Leaving the voice call failed", error);
});
update({ call: null });
if (!call.voiceSessionId) return;
const result = await deps.postRoomAction(call.roomId, {
_action: "LEAVE",
voiceSessionId: call.voiceSessionId,
leaveReason: reason,
});
if (isAskFeedbackResult(result) && result.askFeedback) {
update({
pendingFeedback: {
roomId: call.roomId,
voiceSessionId: call.voiceSessionId,
},
});
}
};
return {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
start: () =>
deps.addServerEventListener((event) => {
if (event.kind !== "voicePresence") return;
const room = snapshot.roomsById.get(event.roomId);
if (!room) return;
setRoom(event.roomId, { ...room, userIds: event.userIds });
}),
loadRoom: async (roomId) => {
const room = await deps.fetchRoom(roomId);
if (room) setRoom(roomId, room);
},
join: async (roomId) => {
if (
snapshot.call?.roomId === roomId &&
snapshot.call.status !== "FAILED"
) {
return;
}
if (snapshot.call) await leave();
const attempt = ++joinAttempt;
update({
pendingFeedback: null,
call: {
roomId,
voiceSessionId: null,
credentials: null,
status: "JOINING",
micEnabled: false,
deafened: false,
inputMode: preferredInputMode,
participants: [],
speakingSessionIds: [],
audioBlocked: false,
errorKind: null,
},
});
latestSummary = null;
await latestJoinRequest;
const isSuperseded = () => attempt !== joinAttempt || !snapshot.call;
if (isSuperseded()) return;
const request = deps.postRoomAction(roomId, {
_action: "JOIN",
platform: deps.platform(),
});
latestJoinRequest = request;
const result = await request;
if (isSuperseded()) {
if (isJoinResult(result)) {
void deps.postRoomAction(roomId, {
_action: "LEAVE",
voiceSessionId: result.voiceSessionId,
leaveReason: "LEFT",
});
}
return;
}
if (!isJoinResult(result)) {
updateCall({ status: "FAILED", errorKind: "JOIN_FAILED" });
reportError("JOIN_FAILED", "join request was refused");
return;
}
updateCall({
voiceSessionId: result.voiceSessionId,
credentials: { roomUrl: result.roomUrl, token: result.token },
});
},
leave,
leaveOnPageClose: () => {
const call = snapshot.call;
if (!call?.voiceSessionId) return;
sendSummary(true);
deps.beaconRoomAction(call.roomId, {
_action: "LEAVE",
voiceSessionId: call.voiceSessionId,
leaveReason: "PAGE_CLOSED",
});
},
kick: async (roomId, userId) => {
await deps.postRoomAction(roomId, { _action: "KICK", userId });
},
setMicEnabled: (enabled) => {
if (!snapshot.call) return;
callHandle?.setLocalAudio(enabled);
updateCall(
enabled
? { micEnabled: true, deafened: false }
: { micEnabled: false, ...silencedLocally(snapshot.call) },
);
},
setDeafened: (deafened) => {
const call = snapshot.call;
if (!call || call.deafened === deafened) return;
if (deafened) {
micEnabledBeforeDeafen = call.micEnabled;
if (call.micEnabled) callHandle?.setLocalAudio(false);
updateCall({
deafened: true,
micEnabled: false,
...silencedLocally(call),
});
return;
}
const restoreMic =
call.inputMode === "OPEN_MIC" && micEnabledBeforeDeafen;
if (restoreMic) callHandle?.setLocalAudio(true);
updateCall({ deafened: false, micEnabled: restoreMic });
},
setInputMode: (mode) => {
preferredInputMode = mode;
if (!snapshot.call || snapshot.call.inputMode === mode) return;
callHandle?.setLocalAudio(false);
updateCall({
inputMode: mode,
micEnabled: false,
...silencedLocally(snapshot.call),
});
},
setVolume: (userId, volume) => {
const volumeByUserId = new Map(snapshot.volumeByUserId);
volumeByUserId.set(userId, Math.min(1, Math.max(0, volume)));
update({ volumeByUserId });
},
registerCallHandle: (handle) => {
callHandle = handle;
},
markConnected: (dailySessionId) => {
const call = snapshot.call;
if (!call?.voiceSessionId) return;
updateCall({ status: "CONNECTED" });
deps.playSound(VOICE_JOIN_SOUND);
void deps.postRoomAction(call.roomId, {
_action: "CONNECTED",
voiceSessionId: call.voiceSessionId,
dailySessionId,
});
},
markFailed: (kind, detail) => {
if (!snapshot.call) return;
updateCall({ status: "FAILED", errorKind: kind });
if (kind) reportError(kind, detail);
},
updateParticipants: (participants) => {
const call = snapshot.call;
if (!call) return;
const mutedSessionIds = participants
.filter((participant) => !participant.audioEnabled)
.map((participant) => participant.sessionId);
const hasBaseline = call.participants.some(
(participant) => participant.isLocal,
);
if (call.status === "CONNECTED" && hasBaseline) {
const remoteSessionIdsOf = (list: VoiceCallParticipant[]) =>
list
.filter((participant) => !participant.isLocal)
.map((participant) => participant.sessionId);
const before = remoteSessionIdsOf(call.participants);
const after = remoteSessionIdsOf(participants);
if (after.some((id) => !before.includes(id))) {
deps.playSound(VOICE_JOIN_SOUND);
}
if (before.some((id) => !after.includes(id))) {
deps.playSound(VOICE_LEAVE_SOUND);
}
}
updateCall({
participants,
speakingSessionIds: call.speakingSessionIds.filter(
(id) => !mutedSessionIds.includes(id),
),
});
},
setSpeaking: (sessionId, speaking) => {
const call = snapshot.call;
if (!call) return;
const participant = call.participants.find(
(candidate) => candidate.sessionId === sessionId,
);
// audio levels stop arriving once a track is muted
if (speaking && participant && !participant.audioEnabled) return;
const isSpeaking = call.speakingSessionIds.includes(sessionId);
if (isSpeaking === speaking) return;
updateCall({
speakingSessionIds: speaking
? [...call.speakingSessionIds, sessionId]
: call.speakingSessionIds.filter((id) => id !== sessionId),
});
},
setAudioBlocked: (blocked) => {
if (snapshot.call?.audioBlocked === blocked) return;
updateCall({ audioBlocked: blocked });
},
flagError: (kind, detail) => {
if (snapshot.call?.errorKind === kind) return;
updateCall({ errorKind: kind });
reportError(kind, detail);
},
resumeAudio: () => {
callHandle?.resumeAudio();
updateCall({ audioBlocked: false });
},
setSummary: (summary) => {
latestSummary = summary;
},
flushSummary: () => sendSummary(false),
submitFeedback: async (feedback) => {
const pending = snapshot.pendingFeedback;
if (!pending) return;
update({ pendingFeedback: null });
await deps.postTelemetry({
kind: "FEEDBACK",
voiceSessionId: pending.voiceSessionId,
...feedback,
});
},
dismissFeedback: () => update({ pendingFeedback: null }),
};
}
function silencedLocally(
call: VoiceCall,
): Pick<VoiceCall, "participants" | "speakingSessionIds"> {
const localSessionId = call.participants.find(
(participant) => participant.isLocal,
)?.sessionId;
return {
participants: call.participants.map((participant) =>
participant.isLocal
? { ...participant, audioEnabled: false }
: participant,
),
speakingSessionIds: call.speakingSessionIds.filter(
(id) => id !== localSessionId,
),
};
}
function isJoinResult(
value: unknown,
): value is { roomUrl: string; token: string; voiceSessionId: number } {
return (
typeof value === "object" &&
value !== null &&
"token" in value &&
"roomUrl" in value &&
"voiceSessionId" in value
);
}
function isAskFeedbackResult(
value: unknown,
): value is { askFeedback: boolean } {
return typeof value === "object" && value !== null && "askFeedback" in value;
}
function detectPlatform(): VoicePlatform {
const userAgent = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(userAgent)) return "IOS";
// iPadOS reports itself as a Mac
if (/Macintosh/.test(userAgent) && navigator.maxTouchPoints > 1) return "IOS";
if (/Android/.test(userAgent)) return "ANDROID";
return "DESKTOP";
}
async function postJson(url: string, body: object) {
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) return null;
const text = await response.text();
return text ? (JSON.parse(text) as unknown) : null;
} catch (error) {
logger.error("Voice request failed", error);
return null;
}
}
function beacon(url: string, body: object) {
navigator.sendBeacon(
url,
new Blob([JSON.stringify(body)], { type: "application/json" }),
);
}
export const voiceClient = createVoiceClient({
fetchRoom: async (roomId) => {
try {
const response = await fetch(voiceRoomDataRoute(roomId));
if (!response.ok) return null;
return (await response.json()) as VoiceRoomState;
} catch (error) {
logger.error("Loading the voice room failed", error);
return null;
}
},
postRoomAction: (roomId, body) => postJson(voiceRoomDataRoute(roomId), body),
beaconRoomAction: (roomId, body) => beacon(voiceRoomDataRoute(roomId), body),
postTelemetry: async (body) => {
await postJson(VOICE_TELEMETRY_ROUTE, body);
},
beaconTelemetry: (body) => beacon(VOICE_TELEMETRY_ROUTE, body),
addServerEventListener: (listener) => eventsClient.addEventListener(listener),
platform: detectPlatform,
playSound: Sounds.play,
});

View File

@@ -0,0 +1,57 @@
import type { ChatRoomType } from "~/features/chat/chat-types";
/** SendouQ only during the beta. */
export const VOICE_ROOM_TYPES = [
"SQ_GROUP",
"SQ_MATCH",
] as const satisfies ChatRoomType[];
export const VOICE_TOKEN_LIFETIME_MINUTES = 120;
export const VOICE_JOIN_SOUND = "voice_join";
export const VOICE_LEAVE_SOUND = "voice_leave";
export const VOICE_MONTHLY_BUDGET_USD = 500;
export const VOICE_FREE_MINUTES_PER_MONTH = 10_000;
export const VOICE_PRICE_PER_MINUTE_USD = 0.00099;
export const VOICE_TELEMETRY_INTERVAL_MS = 60_000;
export const VOICE_FEEDBACK_MIN_SESSION_SECONDS = 120;
export const VOICE_FEEDBACK_COOLDOWN_HOURS = 24;
export const VOICE_FEEDBACK_COMMENT_MAX_LENGTH = 500;
/** How often a room's in-memory presence is reconciled with Daily's */
export const VOICE_PRESENCE_RECONCILE_INTERVAL_MS = 30_000;
export const VOICE_PLATFORMS = ["DESKTOP", "ANDROID", "IOS"] as const;
export type VoicePlatform = (typeof VOICE_PLATFORMS)[number];
export const VOICE_LEAVE_REASONS = [
"LEFT",
"PAGE_CLOSED",
"KICKED",
"EJECTED",
"TOKEN_EXPIRED",
"CONNECTION_LOST",
"REPLACED",
] as const;
export type VoiceLeaveReason = (typeof VOICE_LEAVE_REASONS)[number];
export const VOICE_CLIENT_ERROR_KINDS = [
"MIC_DENIED",
"MIC_NOT_FOUND",
"AUTOPLAY_BLOCKED",
"JOIN_FAILED",
] as const;
export type VoiceClientErrorKind = (typeof VOICE_CLIENT_ERROR_KINDS)[number];
export const VOICE_INPUT_MODES = ["PUSH_TO_TALK", "OPEN_MIC"] as const;
export type VoiceInputMode = (typeof VOICE_INPUT_MODES)[number];
/** Daily's network state as reported in its `participant.left` webhook. */
export const VOICE_NETWORK_QUALITY_STATES = [
"good",
"warning",
"bad",
"unknown",
] as const;
export type VoiceNetworkQualityState =
(typeof VOICE_NETWORK_QUALITY_STATES)[number];

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import type { ChatRoomType } from "~/features/chat/chat-types";
import { useEventStreamCatchUp } from "~/features/events/events-hooks";
import {
type VoiceRoomState,
type VoiceSnapshot,
voiceClient,
} from "./voice-client";
import { VOICE_ROOM_TYPES } from "./voice-constants";
const SERVER_SNAPSHOT: VoiceSnapshot = {
roomsById: new Map(),
call: null,
volumeByUserId: new Map(),
pendingFeedback: null,
};
const getServerSnapshot = () => SERVER_SNAPSHOT;
export function useVoiceSnapshot(): VoiceSnapshot {
return React.useSyncExternalStore(
voiceClient.subscribe,
voiceClient.getSnapshot,
getServerSnapshot,
);
}
export function useVoiceRoom(room: {
id: number;
type: ChatRoomType;
}): VoiceRoomState | null {
const snapshot = useVoiceSnapshot();
const hasVoice = VOICE_ROOM_TYPES.some((type) => type === room.type);
React.useEffect(() => {
if (!hasVoice) return;
void voiceClient.loadRoom(room.id);
}, [hasVoice, room.id]);
useEventStreamCatchUp({
enabled: hasVoice,
onCatchUp: () => void voiceClient.loadRoom(room.id),
});
return hasVoice ? (snapshot.roomsById.get(room.id) ?? null) : null;
}

View File

@@ -0,0 +1,78 @@
import * as v from "valibot";
import { _action, id } from "~/utils/schema";
import {
VOICE_CLIENT_ERROR_KINDS,
VOICE_FEEDBACK_COMMENT_MAX_LENGTH,
VOICE_INPUT_MODES,
VOICE_LEAVE_REASONS,
VOICE_PLATFORMS,
VOICE_TOKEN_LIFETIME_MINUTES,
} from "./voice-constants";
const MAX_SESSION_SECONDS = VOICE_TOKEN_LIFETIME_MINUTES * 60;
const seconds = v.pipe(
v.number(),
v.integer(),
v.minValue(0),
v.maxValue(MAX_SESSION_SECONDS),
);
const clientLeaveReasons = VOICE_LEAVE_REASONS.filter(
(reason) => reason !== "KICKED" && reason !== "REPLACED",
);
export const voiceRoomActionSchema = v.variant("_action", [
v.object({
_action: v.literal("JOIN"),
platform: v.picklist(VOICE_PLATFORMS),
}),
v.object({
_action: v.literal("CONNECTED"),
voiceSessionId: id,
dailySessionId: v.pipe(v.string(), v.minLength(1), v.maxLength(64)),
}),
v.object({
_action: v.literal("LEAVE"),
voiceSessionId: id,
leaveReason: v.picklist(clientLeaveReasons),
}),
v.object({
_action: v.literal("KICK"),
userId: id,
}),
]);
export const voiceTelemetrySchema = v.variant("kind", [
v.object({
kind: v.literal("SUMMARY"),
voiceSessionId: id,
lowQualitySeconds: seconds,
talkSeconds: seconds,
inputMode: v.picklist(VOICE_INPUT_MODES),
}),
v.object({
kind: v.literal("ERROR"),
voiceSessionId: v.nullable(id),
errorKind: v.picklist(VOICE_CLIENT_ERROR_KINDS),
detail: v.nullable(v.pipe(v.string(), v.maxLength(500))),
platform: v.picklist(VOICE_PLATFORMS),
}),
v.object({
kind: v.literal("FEEDBACK"),
voiceSessionId: id,
rating: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(5)),
comment: v.nullable(
v.pipe(
v.string(),
v.trim(),
v.maxLength(VOICE_FEEDBACK_COMMENT_MAX_LENGTH),
),
),
}),
]);
export const voiceDashboardActionSchema = v.union([
v.object({ _action: _action("DISABLE") }),
v.object({ _action: _action("ENABLE") }),
]);

View File

@@ -0,0 +1,11 @@
import { requireUser } from "~/features/auth/core/user.server";
import { isAdmin, isDev } from "~/modules/permissions/utils";
export function requireVoiceDashboardAccess() {
const user = requireUser();
if (!isAdmin(user) && !isDev(user)) {
throw new Response("Forbidden", { status: 403 });
}
return user;
}

View File

@@ -55,6 +55,7 @@ import {
} from "./features/theme/core/provider";
import { getThemeSession } from "./features/theme/core/theme-session.server";
import { timezoneMiddleware } from "./features/timezone/timezone-middleware.server";
import { VoiceCallHost } from "./features/voice/components/VoiceCallHost";
import { UnsavedChangesGuard } from "./form/UnsavedChangesGuard";
import { useHydrated } from "./hooks/useHydrated";
import {
@@ -274,6 +275,7 @@ function Document({
user={rootData?.user}
roomList={rootData?.chatRoomList}
>
{rootData?.user ? <VoiceCallHost /> : null}
<NotificationsProvider user={rootData?.user}>
<LayoutDataProvider data={rootData}>
<GlobalStatusProvider user={rootData?.user}>

View File

@@ -343,6 +343,7 @@ export default [
route("/admin", "features/admin/routes/admin.tsx"),
route("/admin/streams", "features/admin/routes/admin.streams.tsx"),
route("/admin/voice", "features/voice/routes/admin.voice.tsx"),
route("/api/chat/rooms", "features/chat/routes/api.chat.rooms.ts"),
route("/api/chat/rooms/:id", "features/chat/routes/api.chat.rooms.$id.ts"),
route(
@@ -357,6 +358,9 @@ export default [
"/api/chat/rooms/:id/mentions/seen",
"features/chat/routes/api.chat.rooms.$id.mentions.seen.ts",
),
route("/api/voice/rooms/:id", "features/voice/routes/api.voice.rooms.$id.ts"),
route("/api/voice/telemetry", "features/voice/routes/api.voice.telemetry.ts"),
route("/api/voice/webhook", "features/voice/routes/api.voice.webhook.ts"),
route("/api/layout", "features/layout/routes/api.layout.ts"),
route(
"/api/notifications",

View File

@@ -0,0 +1,21 @@
import { subDays } from "date-fns";
import * as VoiceRepository from "../features/voice/VoiceRepository.server";
import { dateToDatabaseTimestamp } from "../utils/dates";
import { Routine } from "./routine.server";
const SESSION_RETENTION_DAYS = 180;
const ERROR_RETENTION_DAYS = 90;
export const DeleteOldVoiceTelemetryRoutine = new Routine({
name: "DeleteOldVoiceTelemetry",
func: async () => {
await VoiceRepository.deleteOlderThan({
sessionsBefore: dateToDatabaseTimestamp(
subDays(new Date(), SESSION_RETENTION_DAYS),
),
errorsBefore: dateToDatabaseTimestamp(
subDays(new Date(), ERROR_RETENTION_DAYS),
),
});
},
});

View File

@@ -9,6 +9,7 @@ import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldPendingFriendRequestsRoutine } from "./deleteOldPendingFriendRequests";
import { DeleteOldScrimPickupRostersRoutine } from "./deleteOldScrimPickupRosters";
import { DeleteOldTournamentAuditLogsRoutine } from "./deleteOldTournamentAuditLogs";
import { DeleteOldVoiceTelemetryRoutine } from "./deleteOldVoiceTelemetry";
import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags";
import { EvictStaleRunningTournamentsRoutine } from "./evictStaleRunningTournaments";
import { ExpireReadyChecksRoutine } from "./expireReadyChecks";
@@ -60,6 +61,7 @@ export const daily = [
NotifyScheduleTeamReminderRoutine,
CloseExpiredCommissionsRoutine,
CloseExpiredChatRoomsRoutine,
DeleteOldVoiceTelemetryRoutine,
DeleteOrphanArtTagsRoutine,
ComputeLutiDivsRoutine,
OptimizeDatabaseRoutine,

View File

@@ -234,6 +234,10 @@ export const chatRoomReadRoute = (roomId: number) =>
export const chatRoomMentionsSeenRoute = (roomId: number) =>
`${chatRoomDataRoute(roomId)}/mentions/seen`;
export const voiceRoomDataRoute = (roomId: number) =>
`/api/voice/rooms/${roomId}`;
export const VOICE_TELEMETRY_ROUTE = "/api/voice/telemetry";
export const userCardFriendshipPage = (userId: number) =>
`/user-card/${userId}/friendship`;

View File

@@ -0,0 +1,9 @@
---
navItem: sendouq
type: feature
---
Voice chat in SendouQ (beta)
- Join voice from your group's or match's chat to talk with your team or everyone in the match
- Open mic or push-to-talk, and user volume settings
- Rate how the call went afterwards, your feedback decides if voice chat will see continued developemt

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "Match",
"chat.room.teamShort": "Team",
"chat.room.scrim": "Scrim",
"chat.voice.title": "Voice",
"chat.voice.beta": "Beta",
"chat.voice.provider": "Voice calls run through Daily, they are never recorded.",
"chat.voice.privacyPolicy": "Daily privacy policy",
"chat.voice.join": "Join voice",
"chat.voice.leave": "Leave voice",
"chat.voice.connecting": "Connecting...",
"chat.voice.connected": "Connected",
"chat.voice.failed": "Could not connect to voice",
"chat.voice.retry": "Retry",
"chat.voice.inCall": "Voice: {{room}}",
"chat.voice.inVoice": "In voice: {{count}}",
"chat.voice.mute": "Mute microphone",
"chat.voice.unmute": "Unmute microphone",
"chat.voice.holdToTalk": "Hold to talk",
"chat.voice.deafen": "Deafen",
"chat.voice.undeafen": "Undeafen",
"chat.voice.enableSound": "Enable sound",
"chat.voice.micDenied": "Microphone access is blocked. You can still listen.",
"chat.voice.micNotFound": "No microphone found. You can still listen.",
"chat.voice.volume": "Volume",
"chat.voice.muteUser": "Mute for me",
"chat.voice.unmuteUser": "Unmute for me",
"chat.voice.kick": "Remove from voice",
"chat.voice.kickConfirm.heading": "Remove {{user}} from voice?",
"chat.voice.kickConfirm.set": "They will be banned from voice chat for the rest of the set.",
"chat.voice.kickConfirm.group": "They will be banned from this group's voice chat.",
"chat.voice.feedback.question": "How was voice chat?",
"chat.voice.feedback.rating": "{{rating}} out of 5",
"chat.voice.feedback.comment": "Anything else to tell us? (optional)",
"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",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "Disallow scrim pickups from non-friends",
"labels.noScreen": "[Accessibility] Avoid Splattercolor Screen",
"labels.spoilerFreeMode": "Spoiler-free mode",
"labels.voicePushToTalk": "Push-to-talk in voice chat",
"bottomTexts.imageDimensions": "Recommended image size: at least {{width}}x{{height}} pixels.",
"imageCrop.heading": "Edit image",
"imageDropzone": "Drop an image here or <action>choose a file</action>",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "When on, only your friends can add you to a scrim pickup.",
"bottomTexts.noScreen": "Affects tournaments, scrims and SendouQ",
"bottomTexts.spoilerFreeMode": "Hides tournament results from the last week",
"bottomTexts.voicePushToTalk": "Your microphone only sends while you hold the talk button",
"options.clockFormat.auto": "Automatic",
"options.clockFormat.24h": "24-hour",
"options.clockFormat.12h": "12-hour",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "Group invitation received",
"sounds.readyCheckStarted": "SendouQ ready check started",
"sounds.matchStarted": "SendouQ match started",
"sounds.tournamentMatchStarted": "Tournament match started"
"sounds.tournamentMatchStarted": "Tournament match started",
"sounds.voiceJoin": "Someone joins voice chat",
"sounds.voiceLeave": "Someone leaves voice chat"
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "Partida",
"chat.room.teamShort": "",
"chat.room.scrim": "Scrim",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"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.",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "No permitir que quienes no son mis amigos me añadan a pickups de scrim",
"labels.noScreen": "[Accesibilidad] Evitar Pantintalla",
"labels.spoilerFreeMode": "Modo sin spoilers",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "Tamaño de imagen recomendado: al menos {{width}}x{{height}} píxeles.",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "Si se activa, solo tus amigos podrán añadirte a un pickup de scrim.",
"bottomTexts.noScreen": "Afecta a torneos, scrims y SendouQ.",
"bottomTexts.spoilerFreeMode": "Oculta los resultados de torneos de la última semana",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "Automático",
"options.clockFormat.24h": "24 horas",
"options.clockFormat.12h": "12 horas",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "Invitación de grupo recibida",
"sounds.readyCheckStarted": "Confirmación de listos de SendouQ iniciada",
"sounds.matchStarted": "Partida de SendouQ iniciada",
"sounds.tournamentMatchStarted": "Set de torneo iniciado"
"sounds.tournamentMatchStarted": "Set de torneo iniciado",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "Partida",
"chat.room.teamShort": "",
"chat.room.scrim": "Scrim",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"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.",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "No permitir que quienes no son mis amigos me agreguen a pickups de scrim",
"labels.noScreen": "[Accesibilidad] Evitar Muro marmoleado",
"labels.spoilerFreeMode": "Modo sin spoilers",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "Tamaño de imagen recomendado: al menos {{width}}x{{height}} píxeles.",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "Si se activa, solo tus amigos podrán agregarte a un pickup de scrim.",
"bottomTexts.noScreen": "Afecta a torneos, scrims y SendouQ.",
"bottomTexts.spoilerFreeMode": "Oculta los resultados de torneos de la última semana",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "Automático",
"options.clockFormat.24h": "24 horas",
"options.clockFormat.12h": "12 horas",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "Invitación de grupo recibida",
"sounds.readyCheckStarted": "Confirmación de listos de SendouQ iniciada",
"sounds.matchStarted": "Partida de SendouQ iniciada",
"sounds.tournamentMatchStarted": "Set de torneo iniciado"
"sounds.tournamentMatchStarted": "Set de torneo iniciado",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "Code ami ",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "Vous avez reçu une invitation de groupe",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "Codice amico",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "フレンドコード",
"fc.whereToFind": "フレンドコードは Nintendo Switch から見ることができます",
"fc.onceSetStaffOnly": "フレンドコードは一度設定すると、変更できるのはスタッフのみです。",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "Código de amigo",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "Код друга",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.spoilerFreeMode": "",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
"sounds.tournamentMatchStarted": "",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -421,6 +421,36 @@
"chat.room.matchShort": "",
"chat.room.teamShort": "",
"chat.room.scrim": "",
"chat.voice.title": "",
"chat.voice.beta": "",
"chat.voice.provider": "",
"chat.voice.privacyPolicy": "",
"chat.voice.join": "",
"chat.voice.leave": "",
"chat.voice.connecting": "",
"chat.voice.connected": "",
"chat.voice.failed": "",
"chat.voice.retry": "",
"chat.voice.inCall": "",
"chat.voice.inVoice": "",
"chat.voice.mute": "",
"chat.voice.unmute": "",
"chat.voice.holdToTalk": "",
"chat.voice.deafen": "",
"chat.voice.undeafen": "",
"chat.voice.enableSound": "",
"chat.voice.micDenied": "",
"chat.voice.micNotFound": "",
"chat.voice.volume": "",
"chat.voice.muteUser": "",
"chat.voice.unmuteUser": "",
"chat.voice.kick": "",
"chat.voice.kickConfirm.heading": "",
"chat.voice.kickConfirm.set": "",
"chat.voice.kickConfirm.group": "",
"chat.voice.feedback.question": "",
"chat.voice.feedback.rating": "",
"chat.voice.feedback.comment": "",
"fc.title": "好友编号",
"fc.whereToFind": "在您的 Nintendo Switch (2) 上找到好友编号",
"fc.onceSetStaffOnly": "设置完成后,只有工作人员能修改",

View File

@@ -31,6 +31,7 @@
"labels.disallowScrimPickupsFromUntrusted": "对抗战:禁止非好友将我加入临时队伍",
"labels.noScreen": "【无障碍】避开含有“浮墨幕墙”的比赛",
"labels.spoilerFreeMode": "隐藏赛事结果模式",
"labels.voicePushToTalk": "",
"bottomTexts.imageDimensions": "",
"imageCrop.heading": "",
"imageDropzone": "",
@@ -41,6 +42,7 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "仅当您作为小组队长位于大厅中时生效。其他小组队长仍然可以拉您进房。",
"bottomTexts.noScreen": "此设置将应用于赛事、对抗战和 SendouQ。",
"bottomTexts.spoilerFreeMode": "隐藏过去一周内的赛事结果。",
"bottomTexts.voicePushToTalk": "",
"options.clockFormat.auto": "自动",
"options.clockFormat.24h": "24 小时制",
"options.clockFormat.12h": "12 小时制",

View File

@@ -11,5 +11,7 @@
"sounds.likeReceived": "收到小组邀请",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "SendouQ 对局开始",
"sounds.tournamentMatchStarted": "赛事对局开始"
"sounds.tournamentMatchStarted": "赛事对局开始",
"sounds.voiceJoin": "",
"sounds.voiceLeave": ""
}

View File

@@ -0,0 +1,151 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("VoiceSession")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
.addColumn("roomId", "integer", (col) =>
col.references("ChatRoom.id").onDelete("set null"),
)
.addColumn("roomType", "text", (col) => col.notNull())
.addColumn("userId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("platform", "text", (col) => col.notNull())
.addColumn("eligibleMemberCount", "integer", (col) => col.notNull())
.addColumn("vcPreference", "text", (col) => col.notNull())
.addColumn("dailySessionId", "text")
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.addColumn("connectedAt", "integer")
.addColumn("leftAt", "integer")
.addColumn("durationSeconds", "integer")
.addColumn("leaveReason", "text")
.addColumn("networkQualityState", "text")
.addColumn("lowQualitySeconds", "integer", (col) =>
col.notNull().defaultTo(0),
)
.addColumn("talkSeconds", "integer", (col) => col.notNull().defaultTo(0))
.addColumn("inputMode", "text")
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("voice_session_created_at")
.on("VoiceSession")
.column("createdAt")
.execute();
await trx.schema
.createIndex("voice_session_room_id_user_id")
.on("VoiceSession")
.columns(["roomId", "userId"])
.execute();
await trx.schema
.createIndex("voice_session_user_id")
.on("VoiceSession")
.column("userId")
.execute();
await trx.schema
.createIndex("voice_session_daily_session_id")
.on("VoiceSession")
.column("dailySessionId")
.unique()
.execute();
await trx.schema
.createTable("VoiceClientError")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
.addColumn("voiceSessionId", "integer", (col) =>
col.references("VoiceSession.id").onDelete("cascade"),
)
.addColumn("userId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("kind", "text", (col) => col.notNull())
.addColumn("detail", "text")
.addColumn("platform", "text", (col) => col.notNull())
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("voice_client_error_created_at")
.on("VoiceClientError")
.column("createdAt")
.execute();
await trx.schema
.createIndex("voice_client_error_voice_session_id")
.on("VoiceClientError")
.column("voiceSessionId")
.execute();
await trx.schema
.createIndex("voice_client_error_user_id")
.on("VoiceClientError")
.column("userId")
.execute();
await trx.schema
.createTable("VoiceFeedback")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
.addColumn("voiceSessionId", "integer", (col) =>
col.notNull().references("VoiceSession.id").onDelete("cascade"),
)
.addColumn("userId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("rating", "integer", (col) => col.notNull())
.addColumn("comment", "text")
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("voice_feedback_voice_session_id")
.on("VoiceFeedback")
.column("voiceSessionId")
.unique()
.execute();
await trx.schema
.createIndex("voice_feedback_user_id_created_at")
.on("VoiceFeedback")
.columns(["userId", "createdAt"])
.execute();
await trx.schema
.createIndex("voice_feedback_created_at")
.on("VoiceFeedback")
.column("createdAt")
.execute();
await trx.schema
.createTable("VoiceSettings")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
.addColumn("isDisabled", "integer", (col) => col.notNull().defaultTo(0))
.addColumn("updatedAt", "integer")
.addColumn("updatedByUserId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("voice_settings_updated_by_user_id")
.on("VoiceSettings")
.column("updatedByUserId")
.execute();
await trx.insertInto("VoiceSettings").values({ isDisabled: 0 }).execute();
});
}

View File

@@ -62,6 +62,8 @@
"dependencies": {
"@aws-sdk/client-s3": "3.1144.0",
"@aws-sdk/lib-storage": "3.1144.0",
"@daily-co/daily-js": "0.93.0",
"@daily-co/daily-react": "0.26.0",
"@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",

124
pnpm-lock.yaml generated
View File

@@ -23,6 +23,12 @@ importers:
'@aws-sdk/lib-storage':
specifier: 3.1144.0
version: 3.1144.0(@aws-sdk/client-s3@3.1144.0)
'@daily-co/daily-js':
specifier: 0.93.0
version: 0.93.0
'@daily-co/daily-react':
specifier: 0.26.0
version: 0.26.0(@daily-co/daily-js@0.93.0)(jotai@2.20.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
'@date-fns/tz':
specifier: 1.5.0
version: 1.5.0
@@ -536,6 +542,19 @@ packages:
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
'@daily-co/daily-js@0.93.0':
resolution: {integrity: sha512-KDo/GuEILe3s7xyIiGSt1mCrtRgSoI7040zoZ+gGWt41mX71/6UhR7AaiyiSg3o7uHXInXPZa9zJ/rJOkrekEQ==}
engines: {node: '>=22.14.0'}
'@daily-co/daily-react@0.26.0':
resolution: {integrity: sha512-RQGWd9YB4bGk8CKO3hn8wyuhISeJwoGhitP8BdvNeqzr4lcTjnPk42vDadQwWsxzlcHaEpEPwO3ujZBP1tUD3A==}
engines: {node: '>=12'}
peerDependencies:
'@daily-co/daily-js': '>=0.68.0 <1'
jotai: ^2
react: ^16.8 || ^17.0 || ^18.0 || ^19.0
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
@@ -1677,6 +1696,30 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@sentry-internal/browser-utils@8.55.2':
resolution: {integrity: sha512-GnKod+gL/Y+1FUM/RGV8q6le1CoyiGbT40MitEK7eVwWe+bfTRq1gN7ioupyHFMUg1RlQkDQ4/sENmio/uow5A==}
engines: {node: '>=14.18'}
'@sentry-internal/feedback@8.55.2':
resolution: {integrity: sha512-XQy//NWbL0mLLM5w8wNDWMNpXz39VUyW2397dUrH8++kR63WhUVAvTOtL0o0GMVadSAzl1b08oHP9zSUNFQwcg==}
engines: {node: '>=14.18'}
'@sentry-internal/replay-canvas@8.55.2':
resolution: {integrity: sha512-P/jGiuR7dRLG9IzD/463fLgiibyYceauav/9prRG0ZxJm1AtuO02OKball2Fs3bbzdzwHCTlcsUuL2ivDF4b5A==}
engines: {node: '>=14.18'}
'@sentry-internal/replay@8.55.2':
resolution: {integrity: sha512-+W43Z697EVe/OgpGW07B773sa8xO1UbpnW0Cr+E+3FMDb6ZbXlaBUoagPTUkkQPdwBe35SDh6r8y2M3EOPGbxg==}
engines: {node: '>=14.18'}
'@sentry/browser@8.55.2':
resolution: {integrity: sha512-xHuPIEKhx9zw5quWvv4YgZprnwoVMCfxIhmOIf6KJ9iizyUHeUDcKpLS59xERroqwX4RpvK+l/27AZu4zfZlzQ==}
engines: {node: '>=14.18'}
'@sentry/core@8.55.2':
resolution: {integrity: sha512-YlEBwybUcOQ/KjMHDmof1vwweVnBtBxYlQp7DE3fOdtW4pqqdHWTnTntQs4VgYfxzjJYgtkd9LHlGtg8qy+JVQ==}
engines: {node: '>=14.18'}
'@smithy/core@3.35.1':
resolution: {integrity: sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==}
engines: {node: '>=18.0.0'}
@@ -3372,6 +3415,30 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jotai-family@1.1.0:
resolution: {integrity: sha512-T1ACRsLnN5Mu0B8/DIWUW1hW7x1RmpBprm/+CWmUTf4ZZUmIFEQpBJ9PNQtXPjdUD2uu8RvwBxr7nJPdCw5BXg==}
engines: {node: '>=12.20.0'}
peerDependencies:
jotai: '>=2.9.0'
jotai@2.20.3:
resolution: {integrity: sha512-N8L9FUbeGDP+0qNJw1FebOxt+5hk+jTOwUA2LlRE4C081jUcY6F1T/FSETp4DT2/INMtiSRZyNm2D+yZdzrSgA==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
'@babel/template': '>=7.0.0'
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@babel/core':
optional: true
'@babel/template':
optional: true
'@types/react':
optional: true
react:
optional: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4873,6 +4940,24 @@ snapshots:
'@blazediff/core@1.9.1': {}
'@daily-co/daily-js@0.93.0':
dependencies:
'@babel/runtime': 7.29.7
'@sentry/browser': 8.55.2
bowser: 2.14.1
dequal: 2.0.3
events: 3.3.0
'@daily-co/daily-react@0.26.0(@daily-co/daily-js@0.93.0)(jotai@2.20.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
dependencies:
'@daily-co/daily-js': 0.93.0
fast-deep-equal: 3.1.3
jotai: 2.20.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.0)
jotai-family: 1.1.0(jotai@2.20.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.0))
lodash.throttle: 4.1.1
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
'@date-fns/tz@1.5.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.3.0)':
@@ -5777,6 +5862,34 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@sentry-internal/browser-utils@8.55.2':
dependencies:
'@sentry/core': 8.55.2
'@sentry-internal/feedback@8.55.2':
dependencies:
'@sentry/core': 8.55.2
'@sentry-internal/replay-canvas@8.55.2':
dependencies:
'@sentry-internal/replay': 8.55.2
'@sentry/core': 8.55.2
'@sentry-internal/replay@8.55.2':
dependencies:
'@sentry-internal/browser-utils': 8.55.2
'@sentry/core': 8.55.2
'@sentry/browser@8.55.2':
dependencies:
'@sentry-internal/browser-utils': 8.55.2
'@sentry-internal/feedback': 8.55.2
'@sentry-internal/replay': 8.55.2
'@sentry-internal/replay-canvas': 8.55.2
'@sentry/core': 8.55.2
'@sentry/core@8.55.2': {}
'@smithy/core@3.35.1':
dependencies:
'@smithy/types': 4.19.0
@@ -7594,6 +7707,17 @@ snapshots:
jiti@2.7.0: {}
jotai-family@1.1.0(jotai@2.20.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.0)):
dependencies:
jotai: 2.20.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.0)
jotai@2.20.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.3.0)(react@19.3.0):
optionalDependencies:
'@babel/core': 7.29.7
'@babel/template': 7.29.7
'@types/react': 19.3.0
react: 19.3.0
js-tokens@4.0.0: {}
js-yaml@3.14.2: