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
+135
View File
@@ -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 };