diff --git a/web/.prettierignore b/web/.prettierignore
index 28cb763..b2b15f2 100644
--- a/web/.prettierignore
+++ b/web/.prettierignore
@@ -2,3 +2,5 @@ dist
node_modules
coverage
*.min.js
+test-results
+playwright-report
diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts
index edd1eb6..b00a69b 100644
--- a/web/src/api/gateway.ts
+++ b/web/src/api/gateway.ts
@@ -1,9 +1,11 @@
import { parseGuildEmojiList } from './emojis';
import { parseChannelPayload } from './gatewayEvents';
+import { parseGuildSoundList } from './sounds';
import { parseVoiceStateList } from './voice';
import { describeUnknown } from '@/lib/http';
import type { GuildEmoji } from './emojis';
+import type { GuildSound } from './sounds';
import type { VoiceState } from './voice';
import type { Channel, Role, UserStatus } from './types';
@@ -49,6 +51,8 @@ export interface GatewayGuild {
roles: Role[];
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
emojis: GuildEmoji[];
+ /** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */
+ sounds: GuildSound[];
/** Кто из участников в голосовых комнатах (AGENT.md 7.14). */
voice_states: VoiceState[];
member_ids: string[];
@@ -240,6 +244,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
channels,
roles,
emojis: parseGuildEmojiList(record['emojis']),
+ sounds: parseGuildSoundList(record['sounds']),
voice_states: parseVoiceStateList(record['voice_states']),
member_ids: asStringArray(record['member_ids']),
my_role_ids: asStringArray(record['my_role_ids']),
diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts
index 2be8b10..1256d32 100644
--- a/web/src/api/gatewayEvents.ts
+++ b/web/src/api/gatewayEvents.ts
@@ -1,7 +1,9 @@
import { parseGuildEmojiList } from './emojis';
+import { parseGuildSoundList } from './sounds';
import { userStatuses, type Channel, type UserStatus } from './types';
import type { GatewayUser } from './gateway';
import type { GuildEmoji } from './emojis';
+import type { GuildSound } from './sounds';
import type { Message, MessageAttachment, MessageReaction } from './messages';
/**
@@ -58,6 +60,22 @@ export interface GuildEmojisUpdateEvent {
emojis: GuildEmoji[];
}
+export interface GuildSoundsUpdateEvent {
+ guild_id: string;
+ sounds: GuildSound[];
+}
+
+/** Событие `SOUNDBOARD_PLAY` (AGENT.md 7.13). */
+export interface SoundboardPlayEvent {
+ guild_id: string;
+ channel_id: string;
+ sound_id: string;
+ file_id: string;
+ name: string;
+ user_id: string;
+ played_at: string;
+}
+
function asRecord(value: unknown): Record | null {
return typeof value === 'object' && value !== null ? (value as Record) : null;
}
@@ -203,6 +221,53 @@ export function parseGuildEmojisUpdate(value: unknown): GuildEmojisUpdateEvent |
return { guild_id: guildId, emojis: parseGuildEmojiList(record['emojis']) };
}
+/**
+ * `GUILD_SOUNDS_UPDATE`: набор звуков сервера изменился (AGENT.md 7.13).
+ * Список приходит целиком — тем же форматом, что в снапшоте READY.
+ */
+export function parseGuildSoundsUpdate(value: unknown): GuildSoundsUpdateEvent | null {
+ const record = asRecord(value);
+ const guildId = asString(record?.['guild_id']);
+ if (record === null || guildId === undefined) {
+ return null;
+ }
+ return { guild_id: guildId, sounds: parseGuildSoundList(record['sounds']) };
+}
+
+/**
+ * `SOUNDBOARD_PLAY`: кто-то сыграл звук в голосовой комнате. Событие
+ * приходит только участникам этой комнаты, и каждый клиент играет звук сам.
+ */
+export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null {
+ const record = asRecord(value);
+ if (record === null) {
+ return null;
+ }
+ const guildId = asString(record['guild_id']);
+ const channelId = asString(record['channel_id']);
+ const soundId = asString(record['sound_id']);
+ const fileId = asString(record['file_id']);
+ const userId = asString(record['user_id']);
+ if (
+ guildId === undefined ||
+ channelId === undefined ||
+ soundId === undefined ||
+ fileId === undefined ||
+ userId === undefined
+ ) {
+ return null;
+ }
+ return {
+ guild_id: guildId,
+ channel_id: channelId,
+ sound_id: soundId,
+ file_id: fileId,
+ name: asString(record['name']) ?? '',
+ user_id: userId,
+ played_at: asString(record['played_at']) ?? '',
+ };
+}
+
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
export function parseChannelPayload(value: unknown): Channel | null {
const record = asRecord(value);
diff --git a/web/src/api/sounds.ts b/web/src/api/sounds.ts
new file mode 100644
index 0000000..b1d7553
--- /dev/null
+++ b/web/src/api/sounds.ts
@@ -0,0 +1,232 @@
+import { request } from './client';
+
+/**
+ * Саундборд и звуковая палитра сервера (AGENT.md 7.13).
+ *
+ * Контракт один и тот же в трёх местах: `GET /guilds/{id}/sounds`, снапшот
+ * READY (`guilds[].sounds`) и событие шлюза `GUILD_SOUNDS_UPDATE`, поэтому
+ * разбор payload'а общий для всех источников. Сам звук — обычный файл
+ * (`/files/{file_id}`), который клиент скачивает и играет локально.
+ */
+
+/** Вид набора: звуки саундборда или звуки интерфейса/событий. */
+export type SoundKind = 'soundboard' | 'ui';
+
+/**
+ * События, к которым сервер разрешает привязать UI-звук (AGENT.md 7.13).
+ * Список — контракт: сервер отвергает всё остальное.
+ */
+export const SOUND_EVENTS = [
+ 'member_join',
+ 'member_leave',
+ 'mention',
+ 'message',
+ 'message_send',
+ 'voice_join',
+ 'voice_leave',
+ 'mic_on',
+ 'mic_off',
+ 'camera_on',
+ 'camera_off',
+ 'screen_on',
+ 'screen_off',
+ 'kick',
+ 'ban',
+ 'mute',
+ 'role_grant',
+ 'role_revoke',
+] as const;
+
+export type SoundEvent = (typeof SOUND_EVENTS)[number];
+
+/** Звук сервера: элемент `sounds` из READY, REST и `GUILD_SOUNDS_UPDATE`. */
+export interface GuildSound {
+ id: string;
+ name: string;
+ file_id: string;
+ kind: SoundKind;
+ /** Событие UI-палитры: только у `kind: 'ui'`. */
+ event?: SoundEvent;
+ /** Необязательная эмодзи-иконка для сетки саундборда. */
+ emoji?: string;
+ /** Ссылка на файл (REST); в снапшоте READY её нет. */
+ url?: string;
+}
+
+/** Лимиты из контракта: 512 КБ на звук, 30 звуков каждого вида на сервер. */
+export const SOUND_MAX_BYTES = 512 * 1024;
+export const SOUND_LIMIT_PER_KIND = 30;
+
+/** Имя звука: та же проверка, что на сервере. */
+export const SOUND_NAME_PATTERN = /^[a-zA-Z0-9_]{2,32}$/u;
+
+/**
+ * Форматы, которые принимает сервер: MP3/OGG/WAV/WebM/M4A. Браузеры
+ * называют одни и те же контейнеры по-разному, поэтому проверяем и MIME,
+ * и расширение файла.
+ */
+export const SOUND_CONTENT_TYPES = [
+ 'audio/mpeg',
+ 'audio/mp3',
+ 'audio/ogg',
+ 'application/ogg',
+ 'audio/wav',
+ 'audio/wave',
+ 'audio/x-wav',
+ 'audio/webm',
+ 'audio/mp4',
+ 'audio/m4a',
+ 'audio/x-m4a',
+] as const;
+
+const SOUND_EXTENSIONS = ['.mp3', '.ogg', '.oga', '.wav', '.webm', '.m4a'] as const;
+
+export const guildSoundsQueryKey = (guildId: string, kind?: SoundKind) =>
+ kind === undefined
+ ? (['guilds', guildId, 'sounds'] as const)
+ : (['guilds', guildId, 'sounds', kind] as const);
+
+function asRecord(value: unknown): Record | null {
+ return typeof value === 'object' && value !== null ? (value as Record) : null;
+}
+
+function asString(value: unknown): string | undefined {
+ return typeof value === 'string' && value !== '' ? value : undefined;
+}
+
+/** Разрешён ли файл по формату (размер проверяется отдельно). */
+export function isAllowedSoundType(file: File): boolean {
+ if ((SOUND_CONTENT_TYPES as readonly string[]).includes(file.type)) {
+ return true;
+ }
+ const name = file.name.toLocaleLowerCase();
+ return SOUND_EXTENSIONS.some((extension) => name.endsWith(extension));
+}
+
+/** Проходит ли имя клиентскую проверку (та же, что на сервере). */
+export function isValidSoundName(name: string): boolean {
+ return SOUND_NAME_PATTERN.test(name);
+}
+
+/** Событие из контракта или `undefined` (неизвестные имена отбрасываем). */
+export function asSoundEvent(value: unknown): SoundEvent | undefined {
+ return typeof value === 'string' && (SOUND_EVENTS as readonly string[]).includes(value)
+ ? (value as SoundEvent)
+ : undefined;
+}
+
+/**
+ * Разбор одного звука. Поле `kind` может отсутствовать (старые снапшоты) —
+ * считаем такой звук саундбордом, как и сервер по умолчанию.
+ */
+export function parseGuildSound(value: unknown): GuildSound | null {
+ const record = asRecord(value);
+ if (record === null) {
+ return null;
+ }
+ const id = asString(record['id']);
+ const name = asString(record['name']);
+ const fileId = asString(record['file_id']);
+ if (id === undefined || name === undefined || fileId === undefined) {
+ return null;
+ }
+ const kind = record['kind'] === 'ui' ? 'ui' : 'soundboard';
+ const sound: GuildSound = { id, name, file_id: fileId, kind };
+ const event = asSoundEvent(record['event']);
+ const emoji = asString(record['emoji']);
+ const url = asString(record['url']);
+ if (event !== undefined) {
+ sound.event = event;
+ }
+ if (emoji !== undefined) {
+ sound.emoji = emoji;
+ }
+ if (url !== undefined) {
+ sound.url = url;
+ }
+ return sound;
+}
+
+/** Разбор списка звуков: битые записи отбрасываются. */
+export function parseGuildSoundList(value: unknown): GuildSound[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value.map(parseGuildSound).filter((item): item is GuildSound => item !== null);
+}
+
+/** Список звуков сервера; без `kind` сервер отдаёт оба набора. */
+export async function fetchGuildSounds(
+ guildId: string,
+ kind?: SoundKind,
+ signal?: AbortSignal,
+): Promise {
+ const query = kind === undefined ? '' : `?kind=${kind}`;
+ const payload = await request<{ sounds?: unknown }>(
+ `/guilds/${encodeURIComponent(guildId)}/sounds${query}`,
+ signal === undefined ? {} : { signal },
+ );
+ return parseGuildSoundList(payload.sounds);
+}
+
+/** Тело загрузки: имя, файл, для палитры — событие, опционально эмодзи. */
+export interface SoundUploadInput {
+ name: string;
+ file: File;
+ event?: SoundEvent;
+ emoji?: string;
+}
+
+/** Загрузка звука: multipart с полями `name`, `file`, `event` и `emoji`. */
+export async function uploadGuildSound(
+ guildId: string,
+ kind: SoundKind,
+ input: SoundUploadInput,
+): Promise {
+ const form = new FormData();
+ form.append('name', input.name);
+ form.append('file', input.file);
+ if (input.event !== undefined) {
+ form.append('event', input.event);
+ }
+ if (input.emoji !== undefined && input.emoji !== '') {
+ form.append('emoji', input.emoji);
+ }
+ const payload = await request<{ sound?: unknown }>(
+ `/guilds/${encodeURIComponent(guildId)}/sounds?kind=${kind}`,
+ { method: 'POST', form },
+ );
+ return parseGuildSound(payload.sound);
+}
+
+/** Переименование: сервер принимает `name` и необязательный `emoji`. */
+export async function renameGuildSound(
+ guildId: string,
+ soundId: string,
+ input: { name: string; emoji?: string },
+): Promise {
+ const payload = await request<{ sound?: unknown }>(
+ `/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`,
+ { method: 'PATCH', body: input },
+ );
+ return parseGuildSound(payload.sound);
+}
+
+export async function deleteGuildSound(guildId: string, soundId: string): Promise {
+ await request<{ ok: true }>(
+ `/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`,
+ { method: 'DELETE' },
+ );
+}
+
+/**
+ * Просьба сыграть звук в голосовой комнате. Сервер проверит право
+ * `USE_SOUNDBOARD`, присутствие в комнате (`voice.not_connected`) и лимит
+ * 3 звука за 10 секунд (`rate_limited`).
+ */
+export async function playSoundboardSound(guildId: string, soundId: string): Promise {
+ await request<{ ok: true }>(
+ `/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}/play`,
+ { method: 'POST' },
+ );
+}
diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx
index e52a1bc..b2d0d89 100644
--- a/web/src/components/chat/MessageItem.tsx
+++ b/web/src/components/chat/MessageItem.tsx
@@ -177,6 +177,13 @@ function SystemMessageBody({ message, authors }: { message: Message; authors: Au
);
}
+ if (event.kind === 'member_leave') {
+ return (
+
+ {t('chat.system.memberLeave', { name: nameOf(event.userId) })}
+
+ );
+ }
if (event.kind === 'member_kick') {
return (
diff --git a/web/src/components/voice/SoundboardNotice.tsx b/web/src/components/voice/SoundboardNotice.tsx
new file mode 100644
index 0000000..b3ad467
--- /dev/null
+++ b/web/src/components/voice/SoundboardNotice.tsx
@@ -0,0 +1,52 @@
+import { useTranslation } from 'react-i18next';
+
+import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
+import { useSoundboardStore } from '@/stores/soundboard';
+
+/**
+ * Всплывающая подпись «{имя} включил звук {name}» (AGENT.md 7.13).
+ *
+ * Живёт в оболочке приложения — рядом с подсказкой о выключенном микрофоне,
+ * поэтому её видно с любого экрана. Гаснет сама: время жизни держит
+ * `stores/soundboard` (3 секунды), компонент только рисует текст.
+ */
+export function SoundboardNotice() {
+ const { t } = useTranslation();
+ const notice = useSoundboardStore((state) => state.notice);
+ const clear = useSoundboardStore((state) => state.clearNotice);
+ // Имя того, кто сыграл звук, резолвит общий справочник авторов.
+ const authors = useAuthorDirectory(
+ notice?.guildId ?? null,
+ notice === null ? [] : [notice.userId],
+ );
+
+ if (notice === null) {
+ return null;
+ }
+
+ return (
+
+
🔊
+
+ {t('voice.soundboard.playedBy', {
+ name: authors.resolve(notice.userId).name,
+ sound: notice.soundName,
+ })}
+
+
+
+ );
+}
diff --git a/web/src/components/voice/VoiceRoomPanel.tsx b/web/src/components/voice/VoiceRoomPanel.tsx
index ac6396f..75b3d1a 100644
--- a/web/src/components/voice/VoiceRoomPanel.tsx
+++ b/web/src/components/voice/VoiceRoomPanel.tsx
@@ -3,13 +3,17 @@ import { useTranslation } from 'react-i18next';
import type { Channel } from '@/api/types';
import { Avatar } from '@/components/ui/Avatar';
+import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
import { Card, Button } from '@/components/ui/primitives';
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
+import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
+import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
import {
+ useCanUseSoundboard,
useVoiceChannels,
useVoiceModeration,
useVoiceOccupants,
@@ -18,8 +22,9 @@ import {
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
import { speakingRingClass } from '@/components/voice/voiceStyles';
import type { VoiceModerationPermissions } from '@/lib/identity';
+import { SCREEN_SHARE_PRESET_IDS, type ScreenSharePresetId } from '@/lib/livekit';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
-import { useVoiceStore } from '@/stores/voice';
+import { useVoiceStore, type VoicePublishQuality } from '@/stores/voice';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
@@ -105,7 +110,11 @@ function VoiceTile({
{occupant.isLocal ? (
- {t('voice.you')}
+ <>
+ {/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
+
+ {t('voice.you')}
+ >
) : (