fix(web): баннер сервера из снапшота, приглашение, встроенные звуки
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера показывает баннер сразу после подключения (AGENT.md 7.4) - GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки - кнопка приглашения в шапке сервера со ссылкой и копированием - встроенные звуки интерфейса на Web Audio с разблокировкой по жесту - object URL превью через безопасные обёртки (jsdom и урезанные webview)
This commit is contained in:
@@ -173,12 +173,7 @@ func (s *Server) finishAppearanceChange(
|
|||||||
}
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.recordAudit(ctx, user, guildID, "guild."+kind.label+"."+action, "guild", &guildID, "")
|
s.recordAudit(ctx, user, guildID, "guild."+kind.label+"."+action, "guild", &guildID, "")
|
||||||
if s.gateway != nil {
|
s.dispatchGuildUpdate(*updated)
|
||||||
s.gateway.Dispatch("GUILD_UPDATE", map[string]any{
|
|
||||||
"guild_id": formatSnowflake(guildID),
|
|
||||||
"name": updated.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
output, err := s.guildOutput(ctx, user, *updated)
|
output, err := s.guildOutput(ctx, user, *updated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeHumaAPIError(w, err)
|
writeHumaAPIError(w, err)
|
||||||
|
|||||||
@@ -269,12 +269,7 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
if s.gateway != nil {
|
s.dispatchGuildUpdate(*guild)
|
||||||
s.gateway.Dispatch("GUILD_UPDATE", map[string]any{
|
|
||||||
"guild_id": formatSnowflake(guildID),
|
|
||||||
"name": guild.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return s.guildOutput(ctx, user, *guild)
|
return s.guildOutput(ctx, user, *guild)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1519,6 +1514,37 @@ func (s *Server) joinGuild(ctx context.Context, user *store.User, guildID uint64
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchGuildUpdate рассылает GUILD_UPDATE со всеми полями оформления:
|
||||||
|
// клиенты обновляют иконку, баннер, splash и акцент без перезагрузки
|
||||||
|
// (AGENT.md 7.4, 11.6).
|
||||||
|
func (s *Server) dispatchGuildUpdate(guild store.Guild) {
|
||||||
|
if s.gateway == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"guild_id": formatSnowflake(guild.ID),
|
||||||
|
"name": guild.Name,
|
||||||
|
"description": guild.Description,
|
||||||
|
"accent_color": guild.AccentColor,
|
||||||
|
}
|
||||||
|
if guild.IconFileID != nil {
|
||||||
|
payload["icon_file_id"] = formatSnowflake(*guild.IconFileID)
|
||||||
|
} else {
|
||||||
|
payload["icon_file_id"] = nil
|
||||||
|
}
|
||||||
|
if guild.BannerFileID != nil {
|
||||||
|
payload["banner_file_id"] = formatSnowflake(*guild.BannerFileID)
|
||||||
|
} else {
|
||||||
|
payload["banner_file_id"] = nil
|
||||||
|
}
|
||||||
|
if guild.SplashFileID != nil {
|
||||||
|
payload["splash_file_id"] = formatSnowflake(*guild.SplashFileID)
|
||||||
|
} else {
|
||||||
|
payload["splash_file_id"] = nil
|
||||||
|
}
|
||||||
|
s.gateway.Dispatch("GUILD_UPDATE", payload)
|
||||||
|
}
|
||||||
|
|
||||||
// deleteGuild удаляет сервер: главный сервер удалить нельзя (AGENT.md 6.3).
|
// deleteGuild удаляет сервер: главный сервер удалить нельзя (AGENT.md 6.3).
|
||||||
func (s *Server) deleteGuild(ctx context.Context, actor *store.User, guildID uint64) error {
|
func (s *Server) deleteGuild(ctx context.Context, actor *store.User, guildID uint64) error {
|
||||||
guild, err := s.store.GetGuild(ctx, guildID)
|
guild, err := s.store.GetGuild(ctx, guildID)
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export interface GatewayGuild {
|
|||||||
name: string;
|
name: string;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
is_main: boolean;
|
is_main: boolean;
|
||||||
|
/** Оформление сервера (AGENT.md 7.4): иконка, баннер, splash и акцент. */
|
||||||
icon_file_id?: string;
|
icon_file_id?: string;
|
||||||
|
banner_file_id?: string;
|
||||||
|
splash_file_id?: string;
|
||||||
|
accent_color?: number;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
||||||
@@ -251,10 +255,22 @@ function parseGuild(value: unknown): GatewayGuild | null {
|
|||||||
my_permissions: asStringArray(record['my_permissions']),
|
my_permissions: asStringArray(record['my_permissions']),
|
||||||
};
|
};
|
||||||
const icon = asString(record['icon_file_id']);
|
const icon = asString(record['icon_file_id']);
|
||||||
|
const banner = asString(record['banner_file_id']);
|
||||||
|
const splash = asString(record['splash_file_id']);
|
||||||
|
const accent = asNumber(record['accent_color']);
|
||||||
const nickname = asString(record['my_nickname']);
|
const nickname = asString(record['my_nickname']);
|
||||||
if (icon !== undefined) {
|
if (icon !== undefined) {
|
||||||
guild.icon_file_id = icon;
|
guild.icon_file_id = icon;
|
||||||
}
|
}
|
||||||
|
if (banner !== undefined) {
|
||||||
|
guild.banner_file_id = banner;
|
||||||
|
}
|
||||||
|
if (splash !== undefined) {
|
||||||
|
guild.splash_file_id = splash;
|
||||||
|
}
|
||||||
|
if (accent !== undefined) {
|
||||||
|
guild.accent_color = accent;
|
||||||
|
}
|
||||||
if (nickname !== undefined) {
|
if (nickname !== undefined) {
|
||||||
guild.my_nickname = nickname;
|
guild.my_nickname = nickname;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export interface GuildUpdateEvent {
|
|||||||
guild_id: string;
|
guild_id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Оформление сервера (AGENT.md 7.4): пустая строка означает «убрано». */
|
||||||
|
icon_file_id?: string;
|
||||||
|
banner_file_id?: string;
|
||||||
|
splash_file_id?: string;
|
||||||
|
accent_color?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuildDeleteEvent {
|
export interface GuildDeleteEvent {
|
||||||
@@ -152,6 +157,16 @@ export function parseGuildUpdate(value: unknown): GuildUpdateEvent | null {
|
|||||||
if (description !== undefined) {
|
if (description !== undefined) {
|
||||||
event.description = description;
|
event.description = description;
|
||||||
}
|
}
|
||||||
|
// Оформление: сервер присылает null, когда файл удалён, — приводим к пустой
|
||||||
|
// строке, чтобы заглушка-буквы вернулась сразу (AGENT.md 11.6).
|
||||||
|
for (const field of ['icon_file_id', 'banner_file_id', 'splash_file_id'] as const) {
|
||||||
|
if (field in record) {
|
||||||
|
event[field] = asString(record[field]) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof record['accent_color'] === 'number') {
|
||||||
|
event.accent_color = record['accent_color'];
|
||||||
|
}
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ export interface InviteGuildPreview {
|
|||||||
is_member: boolean;
|
is_member: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Созданное приглашение (`POST /guilds/{id}/invites`). */
|
||||||
|
export interface Invite {
|
||||||
|
code: string;
|
||||||
|
guild_id: string;
|
||||||
|
channel_id?: string;
|
||||||
|
creator_id?: string;
|
||||||
|
max_uses: number;
|
||||||
|
uses: number;
|
||||||
|
max_age_seconds: number;
|
||||||
|
expires_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
/** Готовая ссылка от сервера (домен и схема инстанса). */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InvitePreview {
|
export interface InvitePreview {
|
||||||
code: string;
|
code: string;
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
@@ -64,6 +79,27 @@ function asString(value: unknown): string | undefined {
|
|||||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateInviteInput {
|
||||||
|
channel_id?: string;
|
||||||
|
max_uses?: number;
|
||||||
|
max_age_seconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /guilds/{id}/invites` — создать приглашение (право CREATE_INVITES).
|
||||||
|
* Ответ — запись приглашения с кодом для ссылки.
|
||||||
|
*/
|
||||||
|
export async function createInvite(
|
||||||
|
guildId: string,
|
||||||
|
input: CreateInviteInput = {},
|
||||||
|
): Promise<Invite> {
|
||||||
|
const payload = await request<{ invite: Invite }>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/invites`,
|
||||||
|
{ method: 'POST', body: input },
|
||||||
|
);
|
||||||
|
return payload.invite;
|
||||||
|
}
|
||||||
|
|
||||||
/** `GET /invites/{code}` — предпросмотр приглашения. */
|
/** `GET /invites/{code}` — предпросмотр приглашения. */
|
||||||
export async function fetchInvite(code: string, signal?: AbortSignal): Promise<InvitePreview> {
|
export async function fetchInvite(code: string, signal?: AbortSignal): Promise<InvitePreview> {
|
||||||
const payload = await request<unknown>(
|
const payload = await request<unknown>(
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { createInvite, type Invite } from '@/api/invites';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { Popover } from '@/components/ui/Popover';
|
||||||
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||||
|
|
||||||
|
/** Время показа подсказки «ссылка скопирована», мс. */
|
||||||
|
export const INVITE_COPIED_HINT_MS = 2_000;
|
||||||
|
|
||||||
|
interface InviteButtonProps {
|
||||||
|
guildId: string;
|
||||||
|
/** Комната, к которой ведёт приглашение (обычно текущая или первая текстовая). */
|
||||||
|
channelId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка «Пригласить» в шапке сервера (AGENT.md 7.15): создаёт приглашение,
|
||||||
|
* показывает ссылку и копирует её в буфер обмена. Ссылка ведёт на страницу
|
||||||
|
* приглашения, где виден splash сервера.
|
||||||
|
*/
|
||||||
|
export function InviteButton({ guildId, channelId }: InviteButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const describeError = useApiErrorMessage();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const close = () => setOpen(false);
|
||||||
|
|
||||||
|
const invite = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createInvite(guildId, channelId === undefined ? {} : { channel_id: channelId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ссылку отдаёт сервер: в ней правильный домен инстанса.
|
||||||
|
const link = invite.data?.url ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!copied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setCopied(false), INVITE_COPIED_HINT_MS);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
const copy = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
setCopied(true);
|
||||||
|
} catch {
|
||||||
|
// Буфер может быть недоступен (нет разрешения): ссылку видно в поле.
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={anchorRef}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={t('channels.invite')}
|
||||||
|
title={t('channels.invite')}
|
||||||
|
data-testid="guild-invite"
|
||||||
|
className="rounded-[var(--radius-sm)] px-1 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen((value) => !value);
|
||||||
|
if (invite.data === undefined && !invite.isPending) {
|
||||||
|
invite.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">🔗</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onClose={close}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
label={t('channels.invite')}
|
||||||
|
placement="bottom"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<div className="flex w-72 flex-col gap-2 p-3" data-testid="guild-invite-panel">
|
||||||
|
<p className="text-sm font-medium">{t('channels.inviteTitle')}</p>
|
||||||
|
<p className="text-xs text-fg-muted">{t('channels.inviteHint')}</p>
|
||||||
|
{invite.isPending ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t('channels.inviteCreating')}</p>
|
||||||
|
) : null}
|
||||||
|
{invite.isError ? (
|
||||||
|
<p className="text-xs text-danger" role="alert">
|
||||||
|
{describeError(invite.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{invite.data === undefined ? null : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={link}
|
||||||
|
aria-label={t('channels.inviteLink')}
|
||||||
|
data-testid="guild-invite-link"
|
||||||
|
className="w-full rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-xs text-fg"
|
||||||
|
onFocus={(event) => event.target.select()}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Подтверждение показываем на самой кнопке: отдельная строка
|
||||||
|
дублировала бы текст и ломала бы поиск по нему. */}
|
||||||
|
<Button data-testid="guild-invite-copy" onClick={() => void copy()}>
|
||||||
|
<span aria-live="polite">
|
||||||
|
{t(copied ? 'channels.inviteCopied' : 'channels.inviteCopy')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="guild-invite-new"
|
||||||
|
disabled={invite.isPending}
|
||||||
|
onClick={() => invite.mutate()}
|
||||||
|
>
|
||||||
|
{t('channels.inviteNew')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Invite };
|
||||||
@@ -239,7 +239,15 @@
|
|||||||
"selectUnread": "Open channel {{name}}, {{count}} unread",
|
"selectUnread": "Open channel {{name}}, {{count}} unread",
|
||||||
"selectMentions": "Open channel {{name}}, {{count}} mentions",
|
"selectMentions": "Open channel {{name}}, {{count}} mentions",
|
||||||
"slowmode": "slowmode {{seconds}}s",
|
"slowmode": "slowmode {{seconds}}s",
|
||||||
"leaveHint": "You can leave the server in server settings → “Danger zone”."
|
"leaveHint": "You can leave the server in server settings → “Danger zone”.",
|
||||||
|
"invite": "Invite to server",
|
||||||
|
"inviteTitle": "Server invite",
|
||||||
|
"inviteHint": "The link expires after a while. Send it to whoever you want to invite.",
|
||||||
|
"inviteCreating": "Creating a link…",
|
||||||
|
"inviteLink": "Invite link",
|
||||||
|
"inviteCopy": "Copy",
|
||||||
|
"inviteCopied": "Copied",
|
||||||
|
"inviteNew": "New link"
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"title": "Voice room",
|
"title": "Voice room",
|
||||||
@@ -1012,7 +1020,9 @@
|
|||||||
"soundboardHint": "Play soundboard sounds in a voice channel.",
|
"soundboardHint": "Play soundboard sounds in a voice channel.",
|
||||||
"latency": "Participant ping",
|
"latency": "Participant ping",
|
||||||
"latencySharing": "Show ping in the voice room",
|
"latencySharing": "Show ping in the voice room",
|
||||||
"latencySharingHint": "Participant latency to the instance updates every second; your own ping is shared with the room."
|
"latencySharingHint": "Participant latency to the instance updates every second; your own ping is shared with the room.",
|
||||||
|
"interfaceSounds": "Built-in interface sounds",
|
||||||
|
"interfaceSoundsHint": "Short signals for messages, mentions and voice joins when the server has no custom sound."
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"title": "Language & region",
|
"title": "Language & region",
|
||||||
|
|||||||
@@ -239,7 +239,15 @@
|
|||||||
"selectUnread": "Открыть комнату {{name}}, непрочитанных: {{count}}",
|
"selectUnread": "Открыть комнату {{name}}, непрочитанных: {{count}}",
|
||||||
"selectMentions": "Открыть комнату {{name}}, упоминаний: {{count}}",
|
"selectMentions": "Открыть комнату {{name}}, упоминаний: {{count}}",
|
||||||
"slowmode": "медленный режим {{seconds}} с",
|
"slowmode": "медленный режим {{seconds}} с",
|
||||||
"leaveHint": "Выйти из сервера можно в настройках сервера → «Опасная зона»."
|
"leaveHint": "Выйти из сервера можно в настройках сервера → «Опасная зона».",
|
||||||
|
"invite": "Пригласить на сервер",
|
||||||
|
"inviteTitle": "Приглашение на сервер",
|
||||||
|
"inviteHint": "Ссылка действует ограниченное время. Отправьте её тому, кого хотите позвать.",
|
||||||
|
"inviteCreating": "Создаём ссылку…",
|
||||||
|
"inviteLink": "Ссылка приглашения",
|
||||||
|
"inviteCopy": "Скопировать",
|
||||||
|
"inviteCopied": "Скопировано",
|
||||||
|
"inviteNew": "Новая ссылка"
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"title": "Голосовая комната",
|
"title": "Голосовая комната",
|
||||||
@@ -1012,7 +1020,9 @@
|
|||||||
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате.",
|
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате.",
|
||||||
"latency": "Пинг участников",
|
"latency": "Пинг участников",
|
||||||
"latencySharing": "Показывать пинг в голосовой комнате",
|
"latencySharing": "Показывать пинг в голосовой комнате",
|
||||||
"latencySharingHint": "Задержка участников до инстанса обновляется раз в секунду; свой пинг публикуется в комнате."
|
"latencySharingHint": "Задержка участников до инстанса обновляется раз в секунду; свой пинг публикуется в комнате.",
|
||||||
|
"interfaceSounds": "Встроенные звуки интерфейса",
|
||||||
|
"interfaceSoundsHint": "Короткие сигналы сообщений, упоминаний и входов в голосовую, если на сервере не загружен свой звук."
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"title": "Язык и регион",
|
"title": "Язык и регион",
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import type { SoundEvent } from '@/api/sounds';
|
||||||
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Встроенные звуки интерфейса (AGENT.md 7.13): короткие сигналы событий, когда
|
||||||
|
* на сервере не загружен свой звук для этого события. Синтезируются через
|
||||||
|
* Web Audio API — бинарных ассетов в бандле нет, а звук можно менять параметрами.
|
||||||
|
*
|
||||||
|
* Звук уважает общие настройки: «звук выключен» (в том числе серверный дефен)
|
||||||
|
* глушит всё, а тумблер «Звуки интерфейса» отключает только встроенные сигналы,
|
||||||
|
* не трогая палитру сервера.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Длительность и высота тона для каждого события палитры. */
|
||||||
|
interface Tone {
|
||||||
|
/** Частоты в герцах: играются одна за другой (арпеджио). */
|
||||||
|
frequencies: number[];
|
||||||
|
/** Длительность одной ноты в секундах. */
|
||||||
|
duration: number;
|
||||||
|
/** Тип волны: короткие сигналы звучат мягче на треугольной. */
|
||||||
|
type: OscillatorType;
|
||||||
|
/** Пиковая громкость относительно общей (0–1). */
|
||||||
|
gain: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ноты событий: подъём — вход, спуск — выход, короткий блип — сообщение. */
|
||||||
|
const TONES: Record<SoundEvent, Tone> = {
|
||||||
|
message: { frequencies: [660], duration: 0.07, type: 'triangle', gain: 0.18 },
|
||||||
|
mention: { frequencies: [880, 1180], duration: 0.08, type: 'triangle', gain: 0.24 },
|
||||||
|
member_join: { frequencies: [520, 780], duration: 0.09, type: 'sine', gain: 0.2 },
|
||||||
|
member_leave: { frequencies: [780, 520], duration: 0.09, type: 'sine', gain: 0.2 },
|
||||||
|
voice_join: { frequencies: [620, 930], duration: 0.08, type: 'sine', gain: 0.22 },
|
||||||
|
voice_leave: { frequencies: [930, 620], duration: 0.08, type: 'sine', gain: 0.22 },
|
||||||
|
mic_on: { frequencies: [740], duration: 0.06, type: 'square', gain: 0.14 },
|
||||||
|
mic_off: { frequencies: [440], duration: 0.06, type: 'square', gain: 0.14 },
|
||||||
|
mute: { frequencies: [380, 300], duration: 0.07, type: 'square', gain: 0.16 },
|
||||||
|
camera_on: { frequencies: [700, 900], duration: 0.06, type: 'triangle', gain: 0.16 },
|
||||||
|
camera_off: { frequencies: [900, 700], duration: 0.06, type: 'triangle', gain: 0.16 },
|
||||||
|
screen_on: { frequencies: [560, 840], duration: 0.07, type: 'sine', gain: 0.18 },
|
||||||
|
screen_off: { frequencies: [840, 560], duration: 0.07, type: 'sine', gain: 0.18 },
|
||||||
|
message_send: { frequencies: [520], duration: 0.05, type: 'triangle', gain: 0.12 },
|
||||||
|
kick: { frequencies: [300, 240], duration: 0.1, type: 'sawtooth', gain: 0.18 },
|
||||||
|
ban: { frequencies: [260, 200], duration: 0.12, type: 'sawtooth', gain: 0.2 },
|
||||||
|
role_grant: { frequencies: [700, 1050], duration: 0.08, type: 'triangle', gain: 0.2 },
|
||||||
|
role_revoke: { frequencies: [1050, 700], duration: 0.08, type: 'triangle', gain: 0.2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Минимальный интерфейс AudioContext: тесты подменяют его своим. */
|
||||||
|
export interface AudioContextLike {
|
||||||
|
currentTime: number;
|
||||||
|
destination: AudioNode;
|
||||||
|
createOscillator: () => OscillatorNode;
|
||||||
|
createGain: () => GainNode;
|
||||||
|
close?: () => Promise<void>;
|
||||||
|
state?: string;
|
||||||
|
resume?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioContextFactory = () => AudioContextLike | null;
|
||||||
|
|
||||||
|
let context: AudioContextLike | null = null;
|
||||||
|
let factory: AudioContextFactory = defaultFactory;
|
||||||
|
/** Сколько звуков играет прямо сейчас: защита от «стены» сигналов. */
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
/** Максимум одновременных встроенных сигналов. */
|
||||||
|
export const INTERFACE_SOUND_MAX_CONCURRENT = 6;
|
||||||
|
|
||||||
|
function defaultFactory(): AudioContextLike | null {
|
||||||
|
const globalWindow = window as unknown as {
|
||||||
|
AudioContext?: new () => AudioContextLike;
|
||||||
|
webkitAudioContext?: new () => AudioContextLike;
|
||||||
|
};
|
||||||
|
const Ctor = globalWindow.AudioContext ?? globalWindow.webkitAudioContext;
|
||||||
|
if (Ctor === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Ctor();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Громкость сигналов: общая громкость приложения, 0 — тишина. */
|
||||||
|
function volume(): number {
|
||||||
|
const { outputVolume, headphonesEnabled } = useVoiceStore.getState();
|
||||||
|
if (!headphonesEnabled) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(1, Math.max(0, outputVolume / 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Частота события: возвращает описание тона или `null`. */
|
||||||
|
export function interfaceTone(event: SoundEvent): Tone | null {
|
||||||
|
return TONES[event] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* playInterfaceSound играет встроенный сигнал события. Тихо ничего не делает,
|
||||||
|
* если звук выключен, событие неизвестно или Web Audio недоступен.
|
||||||
|
*/
|
||||||
|
export function playInterfaceSound(event: SoundEvent): void {
|
||||||
|
if (!useVoiceStore.getState().interfaceSoundsEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const level = volume();
|
||||||
|
if (level <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tone = interfaceTone(event);
|
||||||
|
if (tone === null || active >= INTERFACE_SOUND_MAX_CONCURRENT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = ensureContext();
|
||||||
|
if (ctx === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startedAt = ctx.currentTime;
|
||||||
|
tone.frequencies.forEach((frequency, index) => {
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
oscillator.type = tone.type;
|
||||||
|
oscillator.frequency.value = frequency;
|
||||||
|
const from = startedAt + index * tone.duration;
|
||||||
|
const to = from + tone.duration;
|
||||||
|
// Плавное появление и затухание: без щелчков на краях.
|
||||||
|
gain.gain.setValueAtTime(0, from);
|
||||||
|
gain.gain.linearRampToValueAtTime(tone.gain * level, from + tone.duration * 0.2);
|
||||||
|
gain.gain.linearRampToValueAtTime(0, to);
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
oscillator.start(from);
|
||||||
|
oscillator.stop(to);
|
||||||
|
});
|
||||||
|
active += 1;
|
||||||
|
const total = tone.duration * tone.frequencies.length;
|
||||||
|
window.setTimeout(
|
||||||
|
() => {
|
||||||
|
active = Math.max(0, active - 1);
|
||||||
|
},
|
||||||
|
total * 1000 + 50,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ensureContext лениво создаёт AudioContext: до жеста пользователя он спит. */
|
||||||
|
function ensureContext(): AudioContextLike | null {
|
||||||
|
if (context === null) {
|
||||||
|
context = factory();
|
||||||
|
}
|
||||||
|
if (context === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Браузеры держат контекст в состоянии suspended до взаимодействия.
|
||||||
|
resumeContext(context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resumeContext будит контекст: без этого звук остаётся беззвучным. */
|
||||||
|
function resumeContext(ctx: AudioContextLike | null): void {
|
||||||
|
if (ctx !== null && ctx.state === 'suspended' && ctx.resume !== undefined) {
|
||||||
|
void ctx.resume().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unlockHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
function detachUnlock(): void {
|
||||||
|
if (unlockHandler === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.removeEventListener('pointerdown', unlockHandler);
|
||||||
|
window.removeEventListener('keydown', unlockHandler);
|
||||||
|
unlockHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unlockInterfaceSounds готовит звук к первому жесту: политика автовоспроизведения
|
||||||
|
* не даёт играть сигналам, пришедшим до клика или нажатия клавиши. Вызывается
|
||||||
|
* один раз при старте приложения.
|
||||||
|
*/
|
||||||
|
export function unlockInterfaceSounds(): void {
|
||||||
|
if (unlockHandler !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handler = (): void => {
|
||||||
|
detachUnlock();
|
||||||
|
// Контекст создаётся уже внутри жеста и сразу разбужен.
|
||||||
|
ensureContext();
|
||||||
|
};
|
||||||
|
unlockHandler = handler;
|
||||||
|
window.addEventListener('pointerdown', handler);
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подмена фабрики AudioContext в тестах; `null` возвращает поведение браузера. */
|
||||||
|
export function setAudioContextFactory(next: AudioContextFactory | null): void {
|
||||||
|
factory = next ?? defaultFactory;
|
||||||
|
context = null;
|
||||||
|
active = 0;
|
||||||
|
detachUnlock();
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Ссылки на локальные файлы (превью до ответа сервера). `URL.createObjectURL`
|
||||||
|
* есть не во всех окружениях (jsdom, урезанные webview) и может бросить
|
||||||
|
* исключение, поэтому все вызовы идут через эти обёртки.
|
||||||
|
*/
|
||||||
|
export function safeObjectUrl(source: Blob): string | null {
|
||||||
|
if (typeof URL.createObjectURL !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return URL.createObjectURL(source);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeObjectUrl(url: string | null | undefined): void {
|
||||||
|
if (url === null || url === undefined || typeof URL.revokeObjectURL !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
// Ссылка уже недействительна — освобождать нечего.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fileContentUrl } from '@/api/files';
|
import { fileContentUrl } from '@/api/files';
|
||||||
|
import { safeObjectUrl } from '@/lib/objectUrl';
|
||||||
import { useVoiceStore } from '@/stores/voice';
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,10 +63,7 @@ function resolveSource(fileId: string): Promise<string> {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
if (typeof URL.createObjectURL !== 'function') {
|
return safeObjectUrl(blob) ?? url;
|
||||||
return url;
|
|
||||||
}
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { SoundboardNotice } from '@/components/voice/SoundboardNotice';
|
|||||||
import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice';
|
import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice';
|
||||||
import { usePushToTalk } from '@/components/voice/usePushToTalk';
|
import { usePushToTalk } from '@/components/voice/usePushToTalk';
|
||||||
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
||||||
|
import { unlockInterfaceSounds } from '@/lib/interfaceSounds';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
|
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
|
||||||
@@ -45,6 +46,12 @@ export default function AppLayout() {
|
|||||||
// чтобы работать на любом экране, пока открыта голосовая сессия.
|
// чтобы работать на любом экране, пока открыта голосовая сессия.
|
||||||
usePushToTalk();
|
usePushToTalk();
|
||||||
|
|
||||||
|
// Встроенные звуки (AGENT.md 7.13) разблокируются первым жестом: до него
|
||||||
|
// браузер не даёт играть сигналам, пришедшим из шлюза.
|
||||||
|
useEffect(() => {
|
||||||
|
unlockInterfaceSounds();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const segments = location.pathname.split('/').filter((segment) => segment !== '');
|
const segments = location.pathname.split('/').filter((segment) => segment !== '');
|
||||||
const isFriends = segments[1] === 'friends';
|
const isFriends = segments[1] === 'friends';
|
||||||
const guildId = isFriends || segments[1] === undefined ? null : decodeURIComponent(segments[1]);
|
const guildId = isFriends || segments[1] === undefined ? null : decodeURIComponent(segments[1]);
|
||||||
@@ -137,6 +144,9 @@ export default function AppLayout() {
|
|||||||
my_role_ids: guild.my_role_ids,
|
my_role_ids: guild.my_role_ids,
|
||||||
my_permissions: guild.my_permissions,
|
my_permissions: guild.my_permissions,
|
||||||
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
|
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
|
||||||
|
...(guild.banner_file_id === undefined ? {} : { banner_file_id: guild.banner_file_id }),
|
||||||
|
...(guild.splash_file_id === undefined ? {} : { splash_file_id: guild.splash_file_id }),
|
||||||
|
...(guild.accent_color === undefined ? {} : { accent_color: guild.accent_color }),
|
||||||
});
|
});
|
||||||
}, [fallbackGuild.data, upsertGuild]);
|
}, [fallbackGuild.data, upsertGuild]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NavLink, useNavigate } from 'react-router';
|
|||||||
import { createChannel, fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
|
import { createChannel, fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
|
||||||
import type { Channel } from '@/api/types';
|
import type { Channel } from '@/api/types';
|
||||||
import { ChannelBadge } from '@/components/chat/ChannelBadge';
|
import { ChannelBadge } from '@/components/chat/ChannelBadge';
|
||||||
|
import { InviteButton } from '@/components/guild/InviteButton';
|
||||||
import { useChannelUnread } from '@/components/chat/useChannelUnread';
|
import { useChannelUnread } from '@/components/chat/useChannelUnread';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field, SelectField } from '@/components/ui/Field';
|
import { Field, SelectField } from '@/components/ui/Field';
|
||||||
@@ -24,6 +25,11 @@ interface ChannelSidebarProps {
|
|||||||
channelId: string | null;
|
channelId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** firstTextChannel выбирает комнату, к которой ведёт приглашение. */
|
||||||
|
function firstTextChannel(channels: Channel[]): string | undefined {
|
||||||
|
return channels.find((channel) => channel.type === 'text')?.id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ссылка на комнату с бейджем непрочитанного. Отдельный компонент нужен,
|
* Ссылка на комнату с бейджем непрочитанного. Отдельный компонент нужен,
|
||||||
* чтобы подписка на стор сообщений жила на каждую комнату, а не на весь
|
* чтобы подписка на стор сообщений жила на каждую комнату, а не на весь
|
||||||
@@ -139,6 +145,13 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
currentUser.data?.id,
|
currentUser.data?.id,
|
||||||
currentUser.data?.is_instance_admin === true,
|
currentUser.data?.is_instance_admin === true,
|
||||||
);
|
);
|
||||||
|
// Приглашать может тот, у кого есть CREATE_INVITES (владелец и админ — всегда).
|
||||||
|
const canInvite =
|
||||||
|
guild !== null &&
|
||||||
|
(currentUser.data?.is_instance_admin === true ||
|
||||||
|
guild.owner_id === currentUser.data?.id ||
|
||||||
|
(guild.my_permissions ?? []).includes('ADMINISTRATOR') ||
|
||||||
|
(guild.my_permissions ?? []).includes('CREATE_INVITES'));
|
||||||
// Каналы ещё едут по REST — показываем скелетон вместо «пусто».
|
// Каналы ещё едут по REST — показываем скелетон вместо «пусто».
|
||||||
const channelsLoading = needsChannels && remoteChannels.isPending;
|
const channelsLoading = needsChannels && remoteChannels.isPending;
|
||||||
|
|
||||||
@@ -148,11 +161,30 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside aria-label={t('channels.title')} className="flex min-h-0 flex-1 flex-col bg-surface-1">
|
<aside aria-label={t('channels.title')} className="flex min-h-0 flex-1 flex-col bg-surface-1">
|
||||||
<header className="flex items-center justify-between gap-2 border-b border-border/40 px-3 py-3">
|
<header
|
||||||
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
className="relative flex items-center justify-between gap-2 border-b border-border/40 bg-surface-2 bg-cover bg-center px-3 py-3"
|
||||||
|
data-testid="guild-header"
|
||||||
|
style={
|
||||||
|
guild?.banner_file_id === undefined || guild.banner_file_id === ''
|
||||||
|
? undefined
|
||||||
|
: { backgroundImage: `url(/files/${encodeURIComponent(guild.banner_file_id)})` }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Затемнение: имя сервера должно читаться на любом баннере. */}
|
||||||
|
{guild?.banner_file_id === undefined || guild.banner_file_id === '' ? null : (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 bg-bg/60"
|
||||||
|
data-testid="guild-banner"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h1 className="relative truncate text-sm font-semibold" title={guild?.name}>
|
||||||
{guild?.name ?? t('channels.notInGuild.title')}
|
{guild?.name ?? t('channels.notInGuild.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-1">
|
<div className="relative flex items-center gap-1">
|
||||||
|
{canInvite && guildId !== null ? (
|
||||||
|
<InviteButton guildId={guildId} channelId={firstTextChannel(channels)} />
|
||||||
|
) : null}
|
||||||
{canManageSettings ? (
|
{canManageSettings ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,9 @@ import { myGuildsQueryKey } from '@/api/users';
|
|||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
import { avatarUrl } from '@/lib/identity';
|
import { avatarUrl } from '@/lib/identity';
|
||||||
|
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
||||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
import type { Guild } from '@/api/types';
|
import type { Guild } from '@/api/types';
|
||||||
|
|
||||||
@@ -51,11 +53,42 @@ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectio
|
|||||||
const iconInput = useRef<HTMLInputElement | null>(null);
|
const iconInput = useRef<HTMLInputElement | null>(null);
|
||||||
const bannerInput = useRef<HTMLInputElement | null>(null);
|
const bannerInput = useRef<HTMLInputElement | null>(null);
|
||||||
const splashInput = useRef<HTMLInputElement | null>(null);
|
const splashInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
// Зеркало превью: нужно, чтобы освобождать object URL при закрытии настроек.
|
||||||
|
const previewsRef = useRef<Partial<Record<GuildAppearanceKind, string>>>({});
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
for (const url of Object.values(previewsRef.current)) {
|
||||||
|
revokeObjectUrl(url);
|
||||||
|
}
|
||||||
|
previewsRef.current = {};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Меняет превью вида, освобождая предыдущую ссылку на локальный файл. */
|
||||||
|
const putPreview = (kind: GuildAppearanceKind, url: string | null): void => {
|
||||||
|
revokeObjectUrl(previewsRef.current[kind]);
|
||||||
|
const next = { ...previewsRef.current };
|
||||||
|
if (url === null) {
|
||||||
|
delete next[kind];
|
||||||
|
} else {
|
||||||
|
next[kind] = url;
|
||||||
|
}
|
||||||
|
previewsRef.current = next;
|
||||||
|
setPreviews(next);
|
||||||
|
};
|
||||||
|
|
||||||
const applyGuild = (updated: Guild): void => {
|
const applyGuild = (updated: Guild): void => {
|
||||||
// Карточка сервера и его список в рейке обновляются сразу: оформление
|
// Карточка сервера, рейка и шапка обновляются сразу: рейка берёт иконку из
|
||||||
// видно без перезагрузки (AGENT.md 11.6).
|
// session-store, а не из запроса, поэтому обновляем оба места (AGENT.md 11.6).
|
||||||
queryClient.setQueryData(guildQueryKey(guild.id), updated);
|
queryClient.setQueryData(guildQueryKey(guild.id), updated);
|
||||||
|
useSessionStore.getState().applyGuildAppearance(guild.id, {
|
||||||
|
icon_file_id: updated.icon_file_id ?? '',
|
||||||
|
banner_file_id: updated.banner_file_id ?? '',
|
||||||
|
splash_file_id: updated.splash_file_id ?? '',
|
||||||
|
accent_color: updated.accent_color ?? 0,
|
||||||
|
});
|
||||||
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,7 +97,8 @@ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectio
|
|||||||
uploadGuildAppearance(guild.id, kind, file),
|
uploadGuildAppearance(guild.id, kind, file),
|
||||||
onSuccess: (updated, variables) => {
|
onSuccess: (updated, variables) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setPreviews((current) => ({ ...current, [variables.kind]: undefined }));
|
// Превью заменяет ответ сервера: локальная ссылка больше не нужна.
|
||||||
|
putPreview(variables.kind, null);
|
||||||
applyGuild(updated);
|
applyGuild(updated);
|
||||||
},
|
},
|
||||||
onError: (cause) => setError(cause),
|
onError: (cause) => setError(cause),
|
||||||
@@ -72,8 +106,9 @@ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectio
|
|||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (kind: GuildAppearanceKind) => deleteGuildAppearance(guild.id, kind),
|
mutationFn: (kind: GuildAppearanceKind) => deleteGuildAppearance(guild.id, kind),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated, kind) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
putPreview(kind, null);
|
||||||
applyGuild(updated);
|
applyGuild(updated);
|
||||||
},
|
},
|
||||||
onError: (cause) => setError(cause),
|
onError: (cause) => setError(cause),
|
||||||
@@ -104,7 +139,7 @@ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectio
|
|||||||
setError(new Error(t('settings.servers.appearance.tooLarge')));
|
setError(new Error(t('settings.servers.appearance.tooLarge')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPreviews((current) => ({ ...current, [kind]: URL.createObjectURL(file) }));
|
putPreview(kind, safeObjectUrl(file));
|
||||||
upload.mutate({ kind, file });
|
upload.mutate({ kind, file });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,29 +18,10 @@ import { Badge, Button, Card } from '@/components/ui/primitives';
|
|||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { formatBytes } from '@/lib/format';
|
import { formatBytes } from '@/lib/format';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
/** Ссылка на локальный файл: недоступна в части окружений (и в jsdom). */
|
|
||||||
function safeObjectUrl(file: File): string | null {
|
|
||||||
if (typeof URL.createObjectURL !== 'function') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return URL.createObjectURL(file);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function revokeObjectUrl(url: string): void {
|
|
||||||
try {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch {
|
|
||||||
// Ссылка уже недействительна — освобождать нечего.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Показываем выбранный файл до ответа сервера. */
|
/** Показываем выбранный файл до ответа сервера. */
|
||||||
function useAvatarPreview() {
|
function useAvatarPreview() {
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ export function VoiceSettingsSection() {
|
|||||||
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
||||||
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
||||||
const latencySharingEnabled = useVoiceStore((state) => state.latencySharingEnabled);
|
const latencySharingEnabled = useVoiceStore((state) => state.latencySharingEnabled);
|
||||||
|
const interfaceSoundsEnabled = useVoiceStore((state) => state.interfaceSoundsEnabled);
|
||||||
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
||||||
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
||||||
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
||||||
const setLatencySharingEnabled = useVoiceStore((state) => state.setLatencySharingEnabled);
|
const setLatencySharingEnabled = useVoiceStore((state) => state.setLatencySharingEnabled);
|
||||||
|
const setInterfaceSoundsEnabled = useVoiceStore((state) => state.setInterfaceSoundsEnabled);
|
||||||
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -139,6 +141,16 @@ export function VoiceSettingsSection() {
|
|||||||
<span>{t('settings.voice.eventSounds')}</span>
|
<span>{t('settings.voice.eventSounds')}</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.eventSoundsHint')}</p>
|
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.eventSoundsHint')}</p>
|
||||||
|
<label className="mt-3 flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={interfaceSoundsEnabled}
|
||||||
|
aria-label={t('settings.voice.interfaceSounds')}
|
||||||
|
onChange={(event) => setInterfaceSoundsEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t('settings.voice.interfaceSounds')}</span>
|
||||||
|
</label>
|
||||||
|
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.interfaceSoundsHint')}</p>
|
||||||
<label className="mt-3 flex items-center gap-2 text-sm">
|
<label className="mt-3 flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -164,9 +164,14 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
}
|
}
|
||||||
case 'GUILD_UPDATE': {
|
case 'GUILD_UPDATE': {
|
||||||
const update = parseGuildUpdate(event.d);
|
const update = parseGuildUpdate(event.d);
|
||||||
if (update?.name !== undefined) {
|
if (update === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (update.name !== undefined) {
|
||||||
session.renameGuild(update.guild_id, update.name);
|
session.renameGuild(update.guild_id, update.name);
|
||||||
}
|
}
|
||||||
|
// Оформление меняется мгновенно у всех: иконка, баннер, splash, акцент.
|
||||||
|
session.applyGuildAppearance(update.guild_id, update);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'GUILD_DELETE': {
|
case 'GUILD_DELETE': {
|
||||||
|
|||||||
@@ -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 {
|
export interface SessionGuild extends GuildSummary {
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
/** Кастомные эмодзи сервера: READY и `GUILD_EMOJIS_UPDATE` (AGENT.md 7.12). */
|
/** Кастомные эмодзи сервера: READY и `GUILD_EMOJIS_UPDATE` (AGENT.md 7.12). */
|
||||||
@@ -50,6 +69,8 @@ interface SessionState {
|
|||||||
upsertGuild: (guild: GuildSummary) => void;
|
upsertGuild: (guild: GuildSummary) => void;
|
||||||
/** GUILD_UPDATE: имя сервера (описание в снапшоте Фазы 1 не хранится). */
|
/** GUILD_UPDATE: имя сервера (описание в снапшоте Фазы 1 не хранится). */
|
||||||
renameGuild: (guildId: string, name: string) => void;
|
renameGuild: (guildId: string, name: string) => void;
|
||||||
|
/** Оформление сервера из `GUILD_UPDATE` или ответа API (AGENT.md 7.4). */
|
||||||
|
applyGuildAppearance: (guildId: string, appearance: GuildAppearanceUpdate) => void;
|
||||||
/** Свой никнейм на сервере (PATCH /guilds/{id}/members/{me}). */
|
/** Свой никнейм на сервере (PATCH /guilds/{id}/members/{me}). */
|
||||||
setMyNickname: (guildId: string, nickname: string) => void;
|
setMyNickname: (guildId: string, nickname: string) => void;
|
||||||
removeGuild: (guildId: string) => void;
|
removeGuild: (guildId: string) => void;
|
||||||
@@ -134,9 +155,20 @@ function toSessionGuild(guild: GatewayGuild): SessionGuild {
|
|||||||
emojis: guild.emojis,
|
emojis: guild.emojis,
|
||||||
sounds: guild.sounds,
|
sounds: guild.sounds,
|
||||||
};
|
};
|
||||||
|
// Оформление сервера из снапшота: без него шапка, рейка и акцент терялись
|
||||||
|
// до первого переключения сервера (AGENT.md 7.4).
|
||||||
if (guild.icon_file_id !== undefined) {
|
if (guild.icon_file_id !== undefined) {
|
||||||
summary.icon_file_id = guild.icon_file_id;
|
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) {
|
if (guild.my_nickname !== undefined) {
|
||||||
summary.my_nickname = guild.my_nickname;
|
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) => {
|
setMyNickname: (guildId, nickname) => {
|
||||||
set({
|
set({
|
||||||
guilds: get().guilds.map((guild) =>
|
guilds: get().guilds.map((guild) =>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
|||||||
import type { GuildSound, SoundEvent } from '@/api/sounds';
|
import type { GuildSound, SoundEvent } from '@/api/sounds';
|
||||||
import { playSoundFileInBackground, stopAllSounds } from '@/lib/soundboard';
|
import { playSoundFileInBackground, stopAllSounds } from '@/lib/soundboard';
|
||||||
import { guildSoundsOf, useSessionStore } from '@/stores/session';
|
import { guildSoundsOf, useSessionStore } from '@/stores/session';
|
||||||
|
import { playInterfaceSound } from '@/lib/interfaceSounds';
|
||||||
import { useVoiceStore } from '@/stores/voice';
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
import type { SoundboardPlayEvent } from '@/api/gatewayEvents';
|
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 {
|
export function playEventSound(guildId: string | null, event: SoundEvent): void {
|
||||||
if (guildId === null || !useVoiceStore.getState().eventSoundsEnabled) {
|
if (!useVoiceStore.getState().eventSoundsEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Звук сервера важнее встроенного: администратор мог подобрать свой.
|
||||||
const sound = findEventSound(guildId, event);
|
const sound = findEventSound(guildId, event);
|
||||||
if (sound === null) {
|
if (sound !== null) {
|
||||||
|
playSoundFileInBackground(sound.file_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
playSoundFileInBackground(sound.file_id);
|
playInterfaceSound(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Полная остановка звуков: выход из комнаты, сброс сессии (тесты). */
|
/** Полная остановка звуков: выход из комнаты, сброс сессии (тесты). */
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ export interface VoicePreferences {
|
|||||||
* (AGENT.md 7.14). Выключено — SDK не опрашивается вовсе.
|
* (AGENT.md 7.14). Выключено — SDK не опрашивается вовсе.
|
||||||
*/
|
*/
|
||||||
latencySharingEnabled: boolean;
|
latencySharingEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Встроенные звуки интерфейса (AGENT.md 7.13): короткие сигналы событий,
|
||||||
|
* когда на сервере нет своего звука. Палитру сервера не отключает.
|
||||||
|
*/
|
||||||
|
interfaceSoundsEnabled: boolean;
|
||||||
/** Качество своей публикации видео. */
|
/** Качество своей публикации видео. */
|
||||||
videoQuality: VoicePublishQuality;
|
videoQuality: VoicePublishQuality;
|
||||||
/** Пресет разрешения и fps для демонстрации экрана. */
|
/** Пресет разрешения и fps для демонстрации экрана. */
|
||||||
@@ -87,6 +92,7 @@ interface VoiceState extends VoicePreferences {
|
|||||||
setEventSoundsEnabled: (enabled: boolean) => void;
|
setEventSoundsEnabled: (enabled: boolean) => void;
|
||||||
setSoundboardEnabled: (enabled: boolean) => void;
|
setSoundboardEnabled: (enabled: boolean) => void;
|
||||||
setLatencySharingEnabled: (enabled: boolean) => void;
|
setLatencySharingEnabled: (enabled: boolean) => void;
|
||||||
|
setInterfaceSoundsEnabled: (enabled: boolean) => void;
|
||||||
setVideoQuality: (quality: VoicePublishQuality) => void;
|
setVideoQuality: (quality: VoicePublishQuality) => void;
|
||||||
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
|
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
|
||||||
/** Вернуть исходные предпочтения (используется в тестах). */
|
/** Вернуть исходные предпочтения (используется в тестах). */
|
||||||
@@ -110,6 +116,7 @@ const defaults: VoicePreferences = {
|
|||||||
eventSoundsEnabled: true,
|
eventSoundsEnabled: true,
|
||||||
soundboardEnabled: true,
|
soundboardEnabled: true,
|
||||||
latencySharingEnabled: true,
|
latencySharingEnabled: true,
|
||||||
|
interfaceSoundsEnabled: true,
|
||||||
videoQuality: 'high',
|
videoQuality: 'high',
|
||||||
screenSharePreset: '1080p30',
|
screenSharePreset: '1080p30',
|
||||||
};
|
};
|
||||||
@@ -195,6 +202,10 @@ function initialPreferences(): VoicePreferences {
|
|||||||
stored['latencySharingEnabled'],
|
stored['latencySharingEnabled'],
|
||||||
defaults.latencySharingEnabled,
|
defaults.latencySharingEnabled,
|
||||||
),
|
),
|
||||||
|
interfaceSoundsEnabled: asBoolean(
|
||||||
|
stored['interfaceSoundsEnabled'],
|
||||||
|
defaults.interfaceSoundsEnabled,
|
||||||
|
),
|
||||||
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
|
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
|
||||||
screenSharePreset: asChoice(
|
screenSharePreset: asChoice(
|
||||||
stored['screenSharePreset'],
|
stored['screenSharePreset'],
|
||||||
@@ -225,6 +236,7 @@ function persist(state: VoicePreferences): void {
|
|||||||
eventSoundsEnabled: state.eventSoundsEnabled,
|
eventSoundsEnabled: state.eventSoundsEnabled,
|
||||||
soundboardEnabled: state.soundboardEnabled,
|
soundboardEnabled: state.soundboardEnabled,
|
||||||
latencySharingEnabled: state.latencySharingEnabled,
|
latencySharingEnabled: state.latencySharingEnabled,
|
||||||
|
interfaceSoundsEnabled: state.interfaceSoundsEnabled,
|
||||||
videoQuality: state.videoQuality,
|
videoQuality: state.videoQuality,
|
||||||
screenSharePreset: state.screenSharePreset,
|
screenSharePreset: state.screenSharePreset,
|
||||||
} satisfies VoicePreferences),
|
} satisfies VoicePreferences),
|
||||||
@@ -277,6 +289,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
|
|||||||
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
|
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
|
||||||
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
|
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
|
||||||
setLatencySharingEnabled: (enabled) => patch({ latencySharingEnabled: enabled }),
|
setLatencySharingEnabled: (enabled) => patch({ latencySharingEnabled: enabled }),
|
||||||
|
setInterfaceSoundsEnabled: (enabled) => patch({ interfaceSoundsEnabled: enabled }),
|
||||||
setVideoQuality: (quality) => patch({ videoQuality: quality }),
|
setVideoQuality: (quality) => patch({ videoQuality: quality }),
|
||||||
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
|
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
|
||||||
|
|
||||||
@@ -306,6 +319,7 @@ function preferencesOf(state: VoiceState): VoicePreferences {
|
|||||||
eventSoundsEnabled: state.eventSoundsEnabled,
|
eventSoundsEnabled: state.eventSoundsEnabled,
|
||||||
soundboardEnabled: state.soundboardEnabled,
|
soundboardEnabled: state.soundboardEnabled,
|
||||||
latencySharingEnabled: state.latencySharingEnabled,
|
latencySharingEnabled: state.latencySharingEnabled,
|
||||||
|
interfaceSoundsEnabled: state.interfaceSoundsEnabled,
|
||||||
videoQuality: state.videoQuality,
|
videoQuality: state.videoQuality,
|
||||||
screenSharePreset: state.screenSharePreset,
|
screenSharePreset: state.screenSharePreset,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findRequest,
|
||||||
|
installFetch,
|
||||||
|
installGatewaySocket,
|
||||||
|
json,
|
||||||
|
makeReadySnapshot,
|
||||||
|
makeUser,
|
||||||
|
renderApp,
|
||||||
|
resetStores,
|
||||||
|
type FetchRoute,
|
||||||
|
} from './helpers';
|
||||||
|
import { makeVoiceState } from './voiceRoutes';
|
||||||
|
import {
|
||||||
|
setAudioContextFactory,
|
||||||
|
unlockInterfaceSounds,
|
||||||
|
type AudioContextLike,
|
||||||
|
} from '@/lib/interfaceSounds';
|
||||||
|
import { playEventSound } from '@/stores/soundboard';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Замечания пользователя: иконка должна меняться сразу, баннер сервера виден в
|
||||||
|
* шапке, есть кнопка приглашения, а звуки событий играют даже без своих файлов
|
||||||
|
* на сервере (AGENT.md 7.4, 7.13, 7.15).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const user = makeUser();
|
||||||
|
|
||||||
|
const channels = [{ id: 'c-general', name: 'общий', type: 'text' as const, position: 0 }];
|
||||||
|
|
||||||
|
function guildSummary(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
is_main: true,
|
||||||
|
member_count: 1,
|
||||||
|
my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'],
|
||||||
|
my_role_ids: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function guildDetail(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
...guildSummary(overrides),
|
||||||
|
description: '',
|
||||||
|
roles: [],
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Маршруты оболочки, сервера и приглашений. */
|
||||||
|
function routes(guild: Record<string, unknown> = guildDetail()): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/invites',
|
||||||
|
method: 'POST',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
invite: {
|
||||||
|
code: 'abc123',
|
||||||
|
guild_id: 'g-1',
|
||||||
|
max_uses: 0,
|
||||||
|
uses: 0,
|
||||||
|
max_age_seconds: 86400,
|
||||||
|
created_at: '2026-09-20T10:00:00Z',
|
||||||
|
url: 'https://gl.test/invite/abc123',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/appearance/icon',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ guild: guildDetail({ icon_file_id: '777', banner_file_id: '555' }) }),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildSummary(guild)] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1', response: () => json({ guild }) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Рендерит приложение с READY-снапшотом сервера. */
|
||||||
|
async function bootApp(guild: Record<string, unknown> = guildDetail()) {
|
||||||
|
const snapshot = makeReadySnapshot([
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
is_main: true,
|
||||||
|
my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'],
|
||||||
|
channels,
|
||||||
|
voice_states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })],
|
||||||
|
// Оформление приходит в снапшоте шлюза — так же, как в ReadyGuild.
|
||||||
|
...(typeof guild.banner_file_id === 'string' ? { banner_file_id: guild.banner_file_id } : {}),
|
||||||
|
...(typeof guild.icon_file_id === 'string' ? { icon_file_id: guild.icon_file_id } : {}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const fetchMock = installFetch(routes(guild));
|
||||||
|
const gateway = installGatewaySocket(snapshot);
|
||||||
|
renderApp('/app/g-1/c-general');
|
||||||
|
await gateway.greet();
|
||||||
|
await screen.findByTestId('guild-rail');
|
||||||
|
return { fetchMock, gateway };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('оформление сервера в шапке и рейке', () => {
|
||||||
|
it('показывает баннер в шапке и иконку в рейке', async () => {
|
||||||
|
await bootApp(guildDetail({ banner_file_id: '555', icon_file_id: '777' }));
|
||||||
|
|
||||||
|
const header = await screen.findByTestId('guild-header');
|
||||||
|
expect(header.getAttribute('style') ?? '').toContain('/files/555');
|
||||||
|
expect(within(header).getByTestId('guild-banner')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const rail = screen.getByTestId('guild-rail');
|
||||||
|
expect(rail.querySelector('img')?.getAttribute('src')).toBe('/files/777');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('обновляет иконку сразу после загрузки, без перезагрузки', async () => {
|
||||||
|
const { fetchMock } = await bootApp(guildDetail());
|
||||||
|
|
||||||
|
// Загружаем иконку через секцию оформления в настройках сервера.
|
||||||
|
const dialog = await openServerAppearance();
|
||||||
|
const section = within(dialog).getByTestId('guild-appearance');
|
||||||
|
await userEvent.upload(
|
||||||
|
within(section).getByTestId('appearance-icon-input'),
|
||||||
|
new File(['картинка'], 'icon.png', { type: 'image/png' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findRequest(fetchMock, { url: '/appearance/icon', method: 'POST' })).toBeDefined();
|
||||||
|
});
|
||||||
|
// Рейка берёт иконку из session-store: она обязана обновиться сразу.
|
||||||
|
await waitFor(() => {
|
||||||
|
const rail = screen.getByTestId('guild-rail');
|
||||||
|
expect(rail.querySelector('img')?.getAttribute('src')).toBe('/files/777');
|
||||||
|
});
|
||||||
|
expect(useSessionStore.getState().guilds[0]?.icon_file_id).toBe('777');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('применяет оформление из GUILD_UPDATE другого клиента', async () => {
|
||||||
|
const { gateway } = await bootApp(guildDetail());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
gateway.dispatch('GUILD_UPDATE', {
|
||||||
|
guild_id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
icon_file_id: '999',
|
||||||
|
banner_file_id: '888',
|
||||||
|
accent_color: 0x2ecc71,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useSessionStore.getState().guilds[0]?.icon_file_id).toBe('999');
|
||||||
|
});
|
||||||
|
expect(useSessionStore.getState().guilds[0]?.banner_file_id).toBe('888');
|
||||||
|
const header = screen.getByTestId('guild-header');
|
||||||
|
expect(header.getAttribute('style') ?? '').toContain('/files/888');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Открывает настройки сервера и ждёт секцию оформления. */
|
||||||
|
async function openServerAppearance(): Promise<HTMLElement> {
|
||||||
|
await userEvent.click(await screen.findByLabelText('Настройки сервера'));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
await within(dialog).findByTestId('guild-appearance');
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('приглашение из шапки сервера', () => {
|
||||||
|
it('создаёт ссылку и копирует её', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const { fetchMock } = await bootApp();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByTestId('guild-invite'));
|
||||||
|
const panel = await screen.findByTestId('guild-invite-panel');
|
||||||
|
const link = await within(panel).findByTestId('guild-invite-link');
|
||||||
|
|
||||||
|
expect(findRequest(fetchMock, { url: '/invites', method: 'POST' })).toBeDefined();
|
||||||
|
expect(link).toHaveValue('https://gl.test/invite/abc123');
|
||||||
|
|
||||||
|
await userEvent.click(within(panel).getByTestId('guild-invite-copy'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(writeText).toHaveBeenCalledWith('https://gl.test/invite/abc123');
|
||||||
|
});
|
||||||
|
expect(await within(panel).findByText('Скопировано')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не показывает кнопку без права приглашать', async () => {
|
||||||
|
await bootApp(guildDetail({ my_permissions: [], owner_id: 'user-9' }));
|
||||||
|
|
||||||
|
await screen.findByTestId('guild-header');
|
||||||
|
expect(screen.queryByTestId('guild-invite')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('встроенные звуки интерфейса', () => {
|
||||||
|
/** Простой счётчик осцилляторов: проверяем, что сигнал действительно играет. */
|
||||||
|
function fakeAudio(options: { suspended?: boolean } = {}): {
|
||||||
|
context: AudioContextLike;
|
||||||
|
started: number[];
|
||||||
|
resumed: () => number;
|
||||||
|
} {
|
||||||
|
const started: number[] = [];
|
||||||
|
let resumeCount = 0;
|
||||||
|
const context: AudioContextLike = {
|
||||||
|
currentTime: 0,
|
||||||
|
...(options.suspended === true
|
||||||
|
? {
|
||||||
|
state: 'suspended',
|
||||||
|
resume: () => {
|
||||||
|
resumeCount += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
destination: {} as AudioNode,
|
||||||
|
createOscillator: () =>
|
||||||
|
({
|
||||||
|
type: 'sine',
|
||||||
|
frequency: { value: 0 },
|
||||||
|
connect: () => undefined,
|
||||||
|
start: (when: number) => started.push(when),
|
||||||
|
stop: () => undefined,
|
||||||
|
}) as unknown as OscillatorNode,
|
||||||
|
createGain: () =>
|
||||||
|
({
|
||||||
|
gain: {
|
||||||
|
setValueAtTime: () => undefined,
|
||||||
|
linearRampToValueAtTime: () => undefined,
|
||||||
|
},
|
||||||
|
connect: () => undefined,
|
||||||
|
}) as unknown as GainNode,
|
||||||
|
};
|
||||||
|
return { context, started, resumed: () => resumeCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('играет сигнал события, когда на сервере нет своего звука', () => {
|
||||||
|
const { context, started } = fakeAudio();
|
||||||
|
setAudioContextFactory(() => context);
|
||||||
|
|
||||||
|
playEventSound('g-1', 'message');
|
||||||
|
|
||||||
|
expect(started).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не играет, когда встроенные звуки выключены', () => {
|
||||||
|
const { context, started } = fakeAudio();
|
||||||
|
setAudioContextFactory(() => context);
|
||||||
|
useVoiceStore.setState({ interfaceSoundsEnabled: false });
|
||||||
|
|
||||||
|
playEventSound('g-1', 'message');
|
||||||
|
|
||||||
|
expect(started).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('молчит при выключенном звуке', () => {
|
||||||
|
const { context, started } = fakeAudio();
|
||||||
|
setAudioContextFactory(() => context);
|
||||||
|
useVoiceStore.setState({ headphonesEnabled: false });
|
||||||
|
|
||||||
|
playEventSound('g-1', 'message');
|
||||||
|
|
||||||
|
expect(started).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('уважает звук сервера из палитры', () => {
|
||||||
|
const { context, started } = fakeAudio();
|
||||||
|
setAudioContextFactory(() => context);
|
||||||
|
useSessionStore.setState({
|
||||||
|
guilds: [
|
||||||
|
{
|
||||||
|
...guildSummary(),
|
||||||
|
channels,
|
||||||
|
emojis: [],
|
||||||
|
sounds: [{ id: 's-1', name: 'ping', file_id: '42', kind: 'ui', event: 'message' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
playEventSound('g-1', 'message');
|
||||||
|
|
||||||
|
// Встроенный сигнал не играет: приоритет у звука сервера.
|
||||||
|
expect(started).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('будит звук первым жестом: до него браузер молчит', () => {
|
||||||
|
const { context, resumed } = fakeAudio({ suspended: true });
|
||||||
|
setAudioContextFactory(() => context);
|
||||||
|
unlockInterfaceSounds();
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event('pointerdown'));
|
||||||
|
|
||||||
|
// Контекст создан уже внутри жеста и сразу разбужен.
|
||||||
|
expect(resumed()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -220,6 +220,10 @@ interface SnapshotGuildInput {
|
|||||||
/** Владелец сервера: по умолчанию текущий пользователь (`user-1`). */
|
/** Владелец сервера: по умолчанию текущий пользователь (`user-1`). */
|
||||||
owner_id?: string;
|
owner_id?: string;
|
||||||
icon_file_id?: string;
|
icon_file_id?: string;
|
||||||
|
/** Оформление сервера (AGENT.md 7.4): баннер, splash и акцентный цвет. */
|
||||||
|
banner_file_id?: string;
|
||||||
|
splash_file_id?: string;
|
||||||
|
accent_color?: number;
|
||||||
my_permissions?: string[];
|
my_permissions?: string[];
|
||||||
my_nickname?: string;
|
my_nickname?: string;
|
||||||
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
||||||
@@ -288,6 +292,9 @@ export function makeReadySnapshot(
|
|||||||
owner_id: guild.owner_id ?? 'user-1',
|
owner_id: guild.owner_id ?? 'user-1',
|
||||||
is_main: guild.is_main ?? false,
|
is_main: guild.is_main ?? false,
|
||||||
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
|
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
|
||||||
|
...(guild.banner_file_id === undefined ? {} : { banner_file_id: guild.banner_file_id }),
|
||||||
|
...(guild.splash_file_id === undefined ? {} : { splash_file_id: guild.splash_file_id }),
|
||||||
|
...(guild.accent_color === undefined ? {} : { accent_color: guild.accent_color }),
|
||||||
...(guild.my_nickname === undefined ? {} : { my_nickname: guild.my_nickname }),
|
...(guild.my_nickname === undefined ? {} : { my_nickname: guild.my_nickname }),
|
||||||
channels: (guild.channels ?? []).map((channel, index) => ({
|
channels: (guild.channels ?? []).map((channel, index) => ({
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user