mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 13:06:13 -05:00
Voice chat beta (#3451)
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@container chat-split (height < 800px) {
|
||||
@container chat-split (height < 850px) {
|
||||
.splitTabs {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
47
app/db/seed/factories/VoiceSessionFactory.ts
Normal file
47
app/db/seed/factories/VoiceSessionFactory.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
309
app/features/voice/VoiceRepository.server.test.ts
Normal file
309
app/features/voice/VoiceRepository.server.test.ts
Normal 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() },
|
||||
);
|
||||
}
|
||||
527
app/features/voice/VoiceRepository.server.ts
Normal file
527
app/features/voice/VoiceRepository.server.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
19
app/features/voice/actions/admin.voice.server.ts
Normal file
19
app/features/voice/actions/admin.voice.server.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
187
app/features/voice/components/VoiceBar.module.css
Normal file
187
app/features/voice/components/VoiceBar.module.css
Normal 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);
|
||||
}
|
||||
559
app/features/voice/components/VoiceBar.tsx
Normal file
559
app/features/voice/components/VoiceBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
346
app/features/voice/components/VoiceCall.tsx
Normal file
346
app/features/voice/components/VoiceCall.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
40
app/features/voice/components/VoiceCallHost.tsx
Normal file
40
app/features/voice/components/VoiceCallHost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
app/features/voice/core/Daily.server.test.ts
Normal file
70
app/features/voice/core/Daily.server.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
194
app/features/voice/core/Daily.server.ts
Normal file
194
app/features/voice/core/Daily.server.ts
Normal 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;
|
||||
}
|
||||
107
app/features/voice/core/VoiceAccess.test.ts
Normal file
107
app/features/voice/core/VoiceAccess.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
24
app/features/voice/core/VoiceAccess.ts
Normal file
24
app/features/voice/core/VoiceAccess.ts
Normal 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);
|
||||
}
|
||||
35
app/features/voice/core/VoiceAvailability.server.ts
Normal file
35
app/features/voice/core/VoiceAvailability.server.ts
Normal 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())),
|
||||
);
|
||||
}
|
||||
40
app/features/voice/core/VoiceCost.test.ts
Normal file
40
app/features/voice/core/VoiceCost.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
app/features/voice/core/VoiceCost.ts
Normal file
30
app/features/voice/core/VoiceCost.ts
Normal 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);
|
||||
}
|
||||
83
app/features/voice/core/VoicePresence.server.test.ts
Normal file
83
app/features/voice/core/VoicePresence.server.test.ts
Normal 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 };
|
||||
}
|
||||
138
app/features/voice/core/VoicePresence.server.ts
Normal file
138
app/features/voice/core/VoicePresence.server.ts
Normal 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],
|
||||
},
|
||||
);
|
||||
}
|
||||
105
app/features/voice/loaders/admin.voice.server.ts
Normal file
105
app/features/voice/loaders/admin.voice.server.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
45
app/features/voice/routes/admin.voice.module.css
Normal file
45
app/features/voice/routes/admin.voice.module.css
Normal 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);
|
||||
}
|
||||
539
app/features/voice/routes/admin.voice.tsx
Normal file
539
app/features/voice/routes/admin.voice.tsx
Normal 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 > 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)}`;
|
||||
}
|
||||
90
app/features/voice/routes/api.voice.rooms.$id.test.ts
Normal file
90
app/features/voice/routes/api.voice.rooms.$id.test.ts
Normal 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 ?? []
|
||||
);
|
||||
}
|
||||
186
app/features/voice/routes/api.voice.rooms.$id.ts
Normal file
186
app/features/voice/routes/api.voice.rooms.$id.ts
Normal 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),
|
||||
),
|
||||
}));
|
||||
}
|
||||
56
app/features/voice/routes/api.voice.telemetry.ts
Normal file
56
app/features/voice/routes/api.voice.telemetry.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
89
app/features/voice/routes/api.voice.webhook.ts
Normal file
89
app/features/voice/routes/api.voice.webhook.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
389
app/features/voice/voice-client.test.ts
Normal file
389
app/features/voice/voice-client.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
524
app/features/voice/voice-client.ts
Normal file
524
app/features/voice/voice-client.ts
Normal 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,
|
||||
});
|
||||
57
app/features/voice/voice-constants.ts
Normal file
57
app/features/voice/voice-constants.ts
Normal 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];
|
||||
45
app/features/voice/voice-hooks.ts
Normal file
45
app/features/voice/voice-hooks.ts
Normal 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;
|
||||
}
|
||||
78
app/features/voice/voice-schemas.ts
Normal file
78
app/features/voice/voice-schemas.ts
Normal 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") }),
|
||||
]);
|
||||
11
app/features/voice/voice-utils.server.ts
Normal file
11
app/features/voice/voice-utils.server.ts
Normal 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;
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
21
app/routines/deleteOldVoiceTelemetry.ts
Normal file
21
app/routines/deleteOldVoiceTelemetry.ts
Normal 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),
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
9
changelog/2026-10-05-sendouq-voice-chat.md
Normal file
9
changelog/2026-10-05-sendouq-voice-chat.md
Normal 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
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "フレンドコードは一度設定すると、変更できるのはスタッフのみです。",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
"sounds.tournamentMatchStarted": "",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
@@ -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": "设置完成后,只有工作人员能修改",
|
||||
|
||||
@@ -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 小时制",
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"sounds.likeReceived": "收到小组邀请",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "SendouQ 对局开始",
|
||||
"sounds.tournamentMatchStarted": "赛事对局开始"
|
||||
"sounds.tournamentMatchStarted": "赛事对局开始",
|
||||
"sounds.voiceJoin": "",
|
||||
"sounds.voiceLeave": ""
|
||||
}
|
||||
|
||||
151
migrations/20261005220434-voice-chat.ts
Normal file
151
migrations/20261005220434-voice-chat.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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
124
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user