diff --git a/.env.example b/.env.example index 0c25d95ef..a3feec981 100644 --- a/.env.example +++ b/.env.example @@ -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 (.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= diff --git a/app/components/layout/ChatSidebar.module.css b/app/components/layout/ChatSidebar.module.css index b92d49ff6..c4e617c95 100644 --- a/app/components/layout/ChatSidebar.module.css +++ b/app/components/layout/ChatSidebar.module.css @@ -232,7 +232,7 @@ } } -@container chat-split (height < 800px) { +@container chat-split (height < 850px) { .splitTabs { display: grid; grid-template-rows: auto minmax(0, 1fr); diff --git a/app/components/layout/ChatSidebar.tsx b/app/components/layout/ChatSidebar.tsx index d61ab1688..b5ca3167a 100644 --- a/app/components/layout/ChatSidebar.tsx +++ b/app/components/layout/ChatSidebar.tsx @@ -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 (
+
{!hasAnyRoom ? (
@@ -441,6 +444,8 @@ function SingleChatView({ ) : null}
+ + {room ? : null}
{/* keyed so switching rooms starts the scroller and its unseen state over */} {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({ ) : null}
+
))} + {backgroundCallRoom ? ( + + ) : null}
{rooms.map((room) => (
{roomShortLabel(room, t)}
+
@@ -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 ( + { + 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; diff --git a/app/config.server.ts b/app/config.server.ts index 0649f63c8..6a7736c2d 100644 --- a/app/config.server.ts +++ b/app/config.server.ts @@ -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. */ diff --git a/app/db/seed/factories/VoiceSessionFactory.ts b/app/db/seed/factories/VoiceSessionFactory.ts new file mode 100644 index 000000000..42636920e --- /dev/null +++ b/app/db/seed/factories/VoiceSessionFactory.ts @@ -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[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 }); + }, +}); diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index aad06ae57..9e43f47fc 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -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. */ diff --git a/app/db/tables.ts b/app/db/tables.ts index 842945c5f..d91f86041 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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; youtubeId: string | null; youtubeName: string | null; mapModePreferences: JSONColumnTypeNullable; @@ -1206,6 +1213,60 @@ export interface VideoMatchPlayer { weaponSplId: number; } +export type UserVoiceChat = "YES" | "NO" | "LISTEN_ONLY"; + +export interface VoiceSession { + id: GeneratedAlways; + /** `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; + 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; + talkSeconds: Generated; + inputMode: VoiceInputMode | null; +} + +export interface VoiceClientError { + id: GeneratedAlways; + voiceSessionId: number | null; + userId: number; + kind: VoiceClientErrorKind; + detail: string | null; + platform: VoicePlatform; + createdAt: Generated; +} + +export interface VoiceFeedback { + id: GeneratedAlways; + voiceSessionId: number; + userId: number; + /** 1-5 */ + rating: number; + comment: string | null; + createdAt: Generated; +} + +export interface VoiceSettings { + id: GeneratedAlways; + /** Manual kill switch. */ + isDisabled: Generated; + 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; diff --git a/app/features/events/events-types.ts b/app/features/events/events-types.ts index f66262349..b0cd1efd3 100644 --- a/app/features/events/events-types.ts +++ b/app/features/events/events-types.ts @@ -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[] }; diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 0e2fe7497..7f416fc12 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -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, diff --git a/app/features/settings/components/PreferencesTab.tsx b/app/features/settings/components/PreferencesTab.tsx index 78e465d7b..c885fc5cb 100644 --- a/app/features/settings/components/PreferencesTab.tsx +++ b/app/features/settings/components/PreferencesTab.tsx @@ -18,6 +18,7 @@ import { disableBuildAbilitySortingSchema, disallowScrimPickupsFromUntrustedSchema, spoilerFreeModeSchema, + voicePushToTalkSchema, } from "../settings-schemas"; export function PreferencesTab() { @@ -63,6 +64,17 @@ export function PreferencesTab() { > {({ FormField }) => } + + {({ FormField }) => } +
); diff --git a/app/features/settings/components/SoundsTab.tsx b/app/features/settings/components/SoundsTab.tsx index 6b55f76d8..ba975442a 100644 --- a/app/features/settings/components/SoundsTab.tsx +++ b/app/features/settings/components/SoundsTab.tsx @@ -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( diff --git a/app/features/settings/settings-schemas.ts b/app/features/settings/settings-schemas.ts index d3d5f2d6b..70cb36f35 100644 --- a/app/features/settings/settings-schemas.ts +++ b/app/features/settings/settings-schemas.ts @@ -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, ]); diff --git a/app/features/voice/VoiceRepository.server.test.ts b/app/features/voice/VoiceRepository.server.test.ts new file mode 100644 index 000000000..087e570f9 --- /dev/null +++ b/app/features/voice/VoiceRepository.server.test.ts @@ -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() }, + ); +} diff --git a/app/features/voice/VoiceRepository.server.ts b/app/features/voice/VoiceRepository.server.ts new file mode 100644 index 000000000..f42ded81d --- /dev/null +++ b/app/features/voice/VoiceRepository.server.ts @@ -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`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`date("ChatRoom"."createdAt", 'unixepoch')`; + + return db + .selectFrom("ChatRoom") + .select((eb) => [ + day.as("day"), + "ChatRoom.type", + eb.fn.countAll().as("roomCount"), + eb.fn + .count("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("ChatRoom.id") + .filterWhere( + eb + .selectFrom("VoiceSession") + .select((sub) => + sub.fn + .count("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`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().as("playerCount"), + eb.fn + .countAll() + .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().as("joinCount"), + eb.fn + .count("VoiceSession.id") + .filterWhere("VoiceSession.connectedAt", "is not", null) + .as("connectedCount"), + eb.fn + .count("VoiceSession.userId") + .distinct() + .as("uniqueUserCount"), + eb.fn + .count("VoiceSession.id") + .filterWhere("VoiceSession.talkSeconds", ">", 0) + .as("talkedCount"), + eb.fn.sum("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`coalesce("VoiceSession"."leftAt", ${databaseTimestampNow()}) - "VoiceSession"."connectedAt"`; + + return db + .selectFrom("VoiceSession") + .select((eb) => [ + "VoiceSession.platform", + eb.fn.countAll().as("joinCount"), + eb.fn + .count("VoiceSession.id") + .filterWhere("VoiceSession.connectedAt", "is not", null) + .as("connectedCount"), + eb.fn + .count("VoiceSession.id") + .filterWhere("VoiceSession.connectedAt", "is not", null) + .filterWhere(connectedSeconds, ">", 60) + .as("stayedCount"), + eb.fn + .count("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("VoiceSession.lowQualitySeconds") + .as("lowQualitySeconds"), + eb.fn.sum("VoiceSession.talkSeconds").as("talkSeconds"), + eb.fn + .count("VoiceSession.id") + .filterWhere("VoiceSession.networkQualityState", "=", "bad") + .as("badNetworkCount"), + eb.fn + .count("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().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().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().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("EJECTED"), + ), + })) + .where("VoiceSession.dailySessionId", "=", dailySessionId) + .execute(); +} + +export async function insertOwnClientError( + args: Omit, +) { + 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(); + }); +} diff --git a/app/features/voice/actions/admin.voice.server.ts b/app/features/voice/actions/admin.voice.server.ts new file mode 100644 index 000000000..5c4065980 --- /dev/null +++ b/app/features/voice/actions/admin.voice.server.ts @@ -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; + }, +); diff --git a/app/features/voice/components/VoiceBar.module.css b/app/features/voice/components/VoiceBar.module.css new file mode 100644 index 000000000..343cb5c9f --- /dev/null +++ b/app/features/voice/components/VoiceBar.module.css @@ -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); +} diff --git a/app/features/voice/components/VoiceBar.tsx b/app/features/voice/components/VoiceBar.tsx new file mode 100644 index 000000000..a8a140e87 --- /dev/null +++ b/app/features/voice/components/VoiceBar.tsx @@ -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 ( +
+ {roomCall || voiceRoom ? ( +
+ {roomCall ? ( + + ) : ( + <> + + + + )} +
+ {roomCall ? ( + + ) : voiceRoom?.canJoin ? ( + + ) : null} +
+
+ ) : null} + {roomCall ? : null} + + {feedback ? : null} +
+ ); +} + +export function VoiceCallStrip({ + title, + onOpen, +}: { + title: string; + onOpen: () => void; +}) { + const { t } = useTranslation(["common"]); + const { call } = useVoiceSnapshot(); + + if (!call) return null; + + return ( +
+ +
+ +
+
+ ); +} + +function VoiceLabel() { + const { t } = useTranslation(["common"]); + + return ( + <> + {t("common:chat.voice.title")} + {t("common:chat.voice.beta")} + + ); +} + +function CallStatus({ call }: { call: VoiceCall }) { + const { t } = useTranslation(["common"]); + + return ( + <> + + {call.status === "FAILED" ? ( + + {t("common:chat.voice.failed")} + + ) : call.status === "CONNECTED" ? ( + + ) : ( + + {t("common:chat.voice.connecting")} + + )} + + ); +} + +function CallButtons({ call }: { call: VoiceCall }) { + return ( + <> + {call.status === "FAILED" ? ( + + ) : ( +
+ + +
+ )} + + + ); +} + +function CallNotices({ call }: { call: VoiceCall }) { + const { t } = useTranslation(["common"]); + + return ( + <> + {call.audioBlocked ? ( + } + onClick={() => voiceClient.resumeAudio()} + > + {t("common:chat.voice.enableSound")} + + ) : null} + {call.errorKind === "MIC_DENIED" ? ( + + {t("common:chat.voice.micDenied")} + + ) : call.errorKind === "MIC_NOT_FOUND" ? ( + + {t("common:chat.voice.micNotFound")} + + ) : null} + + ); +} + +function JoinButton({ roomId }: { roomId: number }) { + const { t } = useTranslation(["common"]); + + return ( + } + 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 ( + } + 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 = { + 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 ( + : + } + 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 ( + + ) : ( + + ) + } + 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 ( + } + 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 ( +
    + {entries.map((entry) => { + const user = participants.find( + (candidate) => candidate.id === entry.userId, + ); + + return ( +
  • + {call && !entry.isLocal ? ( + + ) : ( + + )} +
  • + ); + })} +
+ ); +} + +function UserAvatar({ + userId, + user, + speaking, +}: { + userId: number; + user: CommonUser | undefined; + speaking: boolean; +}) { + return ( + + ); +} + +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 ( + <> + + + {volume === 0 ? ( + + ) : null} + + } + > +
+ {user?.username ?? "?"} +
+ : } + aria-label={muteLabel} + title={muteLabel} + onClick={() => + voiceClient.setVolume(userId, volume === 0 ? 1 : 0) + } + /> + + voiceClient.setVolume(userId, Number(event.target.value) / 100) + } + /> +
+ {canKick ? ( + setIsKickConfirmOpen(true)} + > + {t("common:chat.voice.kick")} + + ) : null} +
+
+ {canKick ? ( + void voiceClient.kick(roomId, userId)} + /> + ) : null} + + ); +} + +function StatusDot({ status }: { status: VoiceCall["status"] }) { + return ( + + ); +} + +function FeedbackPrompt() { + const { t } = useTranslation(["common"]); + const user = useUser(); + const [rating, setRating] = React.useState(null); + const [comment, setComment] = React.useState(""); + + if (!user) return null; + + return ( +
+ + {t("common:chat.voice.feedback.question")} + +
+ {RATINGS.map((value) => ( + + ))} +
+ {rating !== null ? ( + <> +