fix(web): баннер сервера из снапшота, приглашение, встроенные звуки
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера показывает баннер сразу после подключения (AGENT.md 7.4) - GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки - кнопка приглашения в шапке сервера со ссылкой и копированием - встроенные звуки интерфейса на Web Audio с разблокировкой по жесту - object URL превью через безопасные обёртки (jsdom и урезанные webview)
This commit is contained in:
@@ -10,6 +10,7 @@ import { SoundboardNotice } from '@/components/voice/SoundboardNotice';
|
||||
import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice';
|
||||
import { usePushToTalk } from '@/components/voice/usePushToTalk';
|
||||
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
||||
import { unlockInterfaceSounds } from '@/lib/interfaceSounds';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
|
||||
@@ -45,6 +46,12 @@ export default function AppLayout() {
|
||||
// чтобы работать на любом экране, пока открыта голосовая сессия.
|
||||
usePushToTalk();
|
||||
|
||||
// Встроенные звуки (AGENT.md 7.13) разблокируются первым жестом: до него
|
||||
// браузер не даёт играть сигналам, пришедшим из шлюза.
|
||||
useEffect(() => {
|
||||
unlockInterfaceSounds();
|
||||
}, []);
|
||||
|
||||
const segments = location.pathname.split('/').filter((segment) => segment !== '');
|
||||
const isFriends = segments[1] === 'friends';
|
||||
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_permissions: guild.my_permissions,
|
||||
...(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]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NavLink, useNavigate } from 'react-router';
|
||||
import { createChannel, fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
|
||||
import type { Channel } from '@/api/types';
|
||||
import { ChannelBadge } from '@/components/chat/ChannelBadge';
|
||||
import { InviteButton } from '@/components/guild/InviteButton';
|
||||
import { useChannelUnread } from '@/components/chat/useChannelUnread';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField } from '@/components/ui/Field';
|
||||
@@ -24,6 +25,11 @@ interface ChannelSidebarProps {
|
||||
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?.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 — показываем скелетон вместо «пусто».
|
||||
const channelsLoading = needsChannels && remoteChannels.isPending;
|
||||
|
||||
@@ -148,11 +161,30 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
||||
<header
|
||||
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')}
|
||||
</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 ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -13,7 +13,9 @@ import { myGuildsQueryKey } from '@/api/users';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { avatarUrl } from '@/lib/identity';
|
||||
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
import type { Guild } from '@/api/types';
|
||||
|
||||
@@ -51,11 +53,42 @@ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectio
|
||||
const iconInput = useRef<HTMLInputElement | null>(null);
|
||||
const bannerInput = 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 => {
|
||||
// Карточка сервера и его список в рейке обновляются сразу: оформление
|
||||
// видно без перезагрузки (AGENT.md 11.6).
|
||||
// Карточка сервера, рейка и шапка обновляются сразу: рейка берёт иконку из
|
||||
// session-store, а не из запроса, поэтому обновляем оба места (AGENT.md 11.6).
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -64,7 +97,8 @@ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectio
|
||||
uploadGuildAppearance(guild.id, kind, file),
|
||||
onSuccess: (updated, variables) => {
|
||||
setError(null);
|
||||
setPreviews((current) => ({ ...current, [variables.kind]: undefined }));
|
||||
// Превью заменяет ответ сервера: локальная ссылка больше не нужна.
|
||||
putPreview(variables.kind, null);
|
||||
applyGuild(updated);
|
||||
},
|
||||
onError: (cause) => setError(cause),
|
||||
@@ -72,8 +106,9 @@ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectio
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (kind: GuildAppearanceKind) => deleteGuildAppearance(guild.id, kind),
|
||||
onSuccess: (updated) => {
|
||||
onSuccess: (updated, kind) => {
|
||||
setError(null);
|
||||
putPreview(kind, null);
|
||||
applyGuild(updated);
|
||||
},
|
||||
onError: (cause) => setError(cause),
|
||||
@@ -104,7 +139,7 @@ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectio
|
||||
setError(new Error(t('settings.servers.appearance.tooLarge')));
|
||||
return;
|
||||
}
|
||||
setPreviews((current) => ({ ...current, [kind]: URL.createObjectURL(file) }));
|
||||
putPreview(kind, safeObjectUrl(file));
|
||||
upload.mutate({ kind, file });
|
||||
};
|
||||
|
||||
|
||||
@@ -18,29 +18,10 @@ import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
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() {
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
@@ -40,10 +40,12 @@ export function VoiceSettingsSection() {
|
||||
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
||||
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
||||
const latencySharingEnabled = useVoiceStore((state) => state.latencySharingEnabled);
|
||||
const interfaceSoundsEnabled = useVoiceStore((state) => state.interfaceSoundsEnabled);
|
||||
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
||||
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
||||
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
||||
const setLatencySharingEnabled = useVoiceStore((state) => state.setLatencySharingEnabled);
|
||||
const setInterfaceSoundsEnabled = useVoiceStore((state) => state.setInterfaceSoundsEnabled);
|
||||
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
||||
|
||||
return (
|
||||
@@ -139,6 +141,16 @@ export function VoiceSettingsSection() {
|
||||
<span>{t('settings.voice.eventSounds')}</span>
|
||||
</label>
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
Reference in New Issue
Block a user