fix(web): баннер сервера из снапшота, приглашение, встроенные звуки

- READY-снапшот больше не теряет banner/splash/accent: шапка сервера
  показывает баннер сразу после подключения (AGENT.md 7.4)
- GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки
- кнопка приглашения в шапке сервера со ссылкой и копированием
- встроенные звуки интерфейса на Web Audio с разблокировкой по жесту
- object URL превью через безопасные обёртки (jsdom и урезанные webview)
This commit is contained in:
2026-09-21 00:04:49 +03:00
parent 6882516340
commit 4ea2965892
22 changed files with 989 additions and 56 deletions
+6 -1
View File
@@ -164,9 +164,14 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
case 'GUILD_UPDATE': {
const update = parseGuildUpdate(event.d);
if (update?.name !== undefined) {
if (update === null) {
return;
}
if (update.name !== undefined) {
session.renameGuild(update.guild_id, update.name);
}
// Оформление меняется мгновенно у всех: иконка, баннер, splash, акцент.
session.applyGuildAppearance(update.guild_id, update);
return;
}
case 'GUILD_DELETE': {
+51
View File
@@ -14,6 +14,25 @@ import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
*/
/** Сервер снапшота: сводка для рейки + каналы/роли/эмодзи для интерфейса. */
/** Изменение оформления сервера: пустая строка убирает файл. */
export interface GuildAppearanceUpdate {
icon_file_id?: string;
banner_file_id?: string;
splash_file_id?: string;
accent_color?: number;
}
/** pickAppearance собирает частичное обновление поля оформления. */
function pickAppearance(
field: 'icon_file_id' | 'banner_file_id' | 'splash_file_id',
value: string | undefined,
): Partial<GuildSummary> {
if (value === undefined) {
return {};
}
return value === '' ? { [field]: undefined } : { [field]: value };
}
export interface SessionGuild extends GuildSummary {
channels: Channel[];
/** Кастомные эмодзи сервера: READY и `GUILD_EMOJIS_UPDATE` (AGENT.md 7.12). */
@@ -50,6 +69,8 @@ interface SessionState {
upsertGuild: (guild: GuildSummary) => void;
/** GUILD_UPDATE: имя сервера (описание в снапшоте Фазы 1 не хранится). */
renameGuild: (guildId: string, name: string) => void;
/** Оформление сервера из `GUILD_UPDATE` или ответа API (AGENT.md 7.4). */
applyGuildAppearance: (guildId: string, appearance: GuildAppearanceUpdate) => void;
/** Свой никнейм на сервере (PATCH /guilds/{id}/members/{me}). */
setMyNickname: (guildId: string, nickname: string) => void;
removeGuild: (guildId: string) => void;
@@ -134,9 +155,20 @@ function toSessionGuild(guild: GatewayGuild): SessionGuild {
emojis: guild.emojis,
sounds: guild.sounds,
};
// Оформление сервера из снапшота: без него шапка, рейка и акцент терялись
// до первого переключения сервера (AGENT.md 7.4).
if (guild.icon_file_id !== undefined) {
summary.icon_file_id = guild.icon_file_id;
}
if (guild.banner_file_id !== undefined) {
summary.banner_file_id = guild.banner_file_id;
}
if (guild.splash_file_id !== undefined) {
summary.splash_file_id = guild.splash_file_id;
}
if (guild.accent_color !== undefined) {
summary.accent_color = guild.accent_color;
}
if (guild.my_nickname !== undefined) {
summary.my_nickname = guild.my_nickname;
}
@@ -250,6 +282,25 @@ export const useSessionStore = create<SessionState>((set, get) => ({
});
},
applyGuildAppearance: (guildId, appearance) => {
set({
guilds: get().guilds.map((guild) =>
guild.id === guildId
? {
...guild,
// Пустая строка означает «файла больше нет»: поле снимается.
...pickAppearance('icon_file_id', appearance.icon_file_id),
...pickAppearance('banner_file_id', appearance.banner_file_id),
...pickAppearance('splash_file_id', appearance.splash_file_id),
...(appearance.accent_color === undefined
? {}
: { accent_color: appearance.accent_color }),
}
: guild,
),
});
},
setMyNickname: (guildId, nickname) => {
set({
guilds: get().guilds.map((guild) =>
+9 -6
View File
@@ -3,6 +3,7 @@ import { create } from 'zustand';
import type { GuildSound, SoundEvent } from '@/api/sounds';
import { playSoundFileInBackground, stopAllSounds } from '@/lib/soundboard';
import { guildSoundsOf, useSessionStore } from '@/stores/session';
import { playInterfaceSound } from '@/lib/interfaceSounds';
import { useVoiceStore } from '@/stores/voice';
import type { SoundboardPlayEvent } from '@/api/gatewayEvents';
@@ -129,19 +130,21 @@ export function findEventSound(guildId: string | null, event: SoundEvent): Guild
}
/**
* Проигрывает звук события сервера, если он задан и звуки событий включены.
* Вызывается из диспетчера шлюза и из голосовой сессии: молчание — норма,
* когда звук не настроен или пользователь его выключил.
* Проигрывает звук события: сначала свой звук сервера из палитры, а если его
* нет — встроенный сигнал интерфейса (AGENT.md 7.13). Вызывается из диспетчера
* шлюза и из голосовой сессии; молчание — норма, когда звук выключен.
*/
export function playEventSound(guildId: string | null, event: SoundEvent): void {
if (guildId === null || !useVoiceStore.getState().eventSoundsEnabled) {
if (!useVoiceStore.getState().eventSoundsEnabled) {
return;
}
// Звук сервера важнее встроенного: администратор мог подобрать свой.
const sound = findEventSound(guildId, event);
if (sound === null) {
if (sound !== null) {
playSoundFileInBackground(sound.file_id);
return;
}
playSoundFileInBackground(sound.file_id);
playInterfaceSound(event);
}
/** Полная остановка звуков: выход из комнаты, сброс сессии (тесты). */
+14
View File
@@ -64,6 +64,11 @@ export interface VoicePreferences {
* (AGENT.md 7.14). Выключено — SDK не опрашивается вовсе.
*/
latencySharingEnabled: boolean;
/**
* Встроенные звуки интерфейса (AGENT.md 7.13): короткие сигналы событий,
* когда на сервере нет своего звука. Палитру сервера не отключает.
*/
interfaceSoundsEnabled: boolean;
/** Качество своей публикации видео. */
videoQuality: VoicePublishQuality;
/** Пресет разрешения и fps для демонстрации экрана. */
@@ -87,6 +92,7 @@ interface VoiceState extends VoicePreferences {
setEventSoundsEnabled: (enabled: boolean) => void;
setSoundboardEnabled: (enabled: boolean) => void;
setLatencySharingEnabled: (enabled: boolean) => void;
setInterfaceSoundsEnabled: (enabled: boolean) => void;
setVideoQuality: (quality: VoicePublishQuality) => void;
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
/** Вернуть исходные предпочтения (используется в тестах). */
@@ -110,6 +116,7 @@ const defaults: VoicePreferences = {
eventSoundsEnabled: true,
soundboardEnabled: true,
latencySharingEnabled: true,
interfaceSoundsEnabled: true,
videoQuality: 'high',
screenSharePreset: '1080p30',
};
@@ -195,6 +202,10 @@ function initialPreferences(): VoicePreferences {
stored['latencySharingEnabled'],
defaults.latencySharingEnabled,
),
interfaceSoundsEnabled: asBoolean(
stored['interfaceSoundsEnabled'],
defaults.interfaceSoundsEnabled,
),
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
screenSharePreset: asChoice(
stored['screenSharePreset'],
@@ -225,6 +236,7 @@ function persist(state: VoicePreferences): void {
eventSoundsEnabled: state.eventSoundsEnabled,
soundboardEnabled: state.soundboardEnabled,
latencySharingEnabled: state.latencySharingEnabled,
interfaceSoundsEnabled: state.interfaceSoundsEnabled,
videoQuality: state.videoQuality,
screenSharePreset: state.screenSharePreset,
} satisfies VoicePreferences),
@@ -277,6 +289,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
setLatencySharingEnabled: (enabled) => patch({ latencySharingEnabled: enabled }),
setInterfaceSoundsEnabled: (enabled) => patch({ interfaceSoundsEnabled: enabled }),
setVideoQuality: (quality) => patch({ videoQuality: quality }),
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
@@ -306,6 +319,7 @@ function preferencesOf(state: VoiceState): VoicePreferences {
eventSoundsEnabled: state.eventSoundsEnabled,
soundboardEnabled: state.soundboardEnabled,
latencySharingEnabled: state.latencySharingEnabled,
interfaceSoundsEnabled: state.interfaceSoundsEnabled,
videoQuality: state.videoQuality,
screenSharePreset: state.screenSharePreset,
};