feat(channels): фон комнаты и статичные превью при reduced-motion

- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
  фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
  комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
  нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
This commit is contained in:
2026-09-21 18:08:21 +03:00
parent 91f415e36d
commit 0f0060e8a4
19 changed files with 733 additions and 16 deletions
@@ -0,0 +1,175 @@
import { useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { deleteChannelBackground, fileContentUrl, uploadChannelBackground } from '@/api/files';
import { guildChannelsQueryKey } from '@/api/guilds';
import { AnimatedImage } from '@/components/ui/AnimatedImage';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button } from '@/components/ui/primitives';
import { SettingsSection } from '@/pages/settings/SettingsSection';
import { useSessionStore } from '@/stores/session';
import type { Channel } from '@/api/types';
/** Предел размера фона — 5 МБ (AGENT.md 7.7). */
export const BACKGROUND_MAX_BYTES = 5 * 1024 * 1024;
interface ChannelBackgroundSectionProps {
channelId: string;
backgroundFileId: string | undefined;
/** Право MANAGE_CHANNEL_BACKGROUND: без него секция не показывается. */
canManage: boolean;
}
/**
* «Фон комнаты» (AGENT.md 7.5, 7.7): картинка за лентой сообщений. Файл
* хранится как есть, поэтому анимированные GIF/APNG/WebP сохраняют анимацию
* (при «уменьшить движение» показывается первый кадр).
*/
export function ChannelBackgroundSection({
channelId,
backgroundFileId,
canManage,
}: ChannelBackgroundSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const inputRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<unknown>(null);
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
const guilds = useSessionStore((state) => state.guilds);
const guildId = guilds.find((guild) =>
guild.channels.some((channel) => channel.id === channelId),
)?.id;
const invalidate = (): void => {
if (guildId !== undefined) {
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId) });
}
};
const upload = useMutation({
mutationFn: (file: File) => uploadChannelBackground(channelId, file),
onSuccess: (channel) => {
setError(null);
applyLocal(channel.background_file_id);
invalidate();
},
onError: (cause: unknown) => setError(cause),
});
const remove = useMutation({
mutationFn: () => deleteChannelBackground(channelId),
onSuccess: () => {
setError(null);
applyLocal(undefined);
invalidate();
},
onError: (cause: unknown) => setError(cause),
});
/** Обновляем снапшот сессии: лента берёт фон именно оттуда. */
function applyLocal(fileId: string | undefined): void {
if (guildId === undefined) {
return;
}
const guild = guilds.find((item) => item.id === guildId);
if (guild === undefined) {
return;
}
setGuildChannels(
guildId,
guild.channels.map((channel): Channel => {
if (channel.id !== channelId) {
return channel;
}
if (fileId === undefined || fileId === '') {
// Поле снимается целиком: пустая ссылка означает «фона нет».
const { background_file_id: _removed, ...rest } = channel;
return rest;
}
return { ...channel, background_file_id: fileId };
}),
);
}
if (!canManage) {
return null;
}
const pick = (file: File | undefined): void => {
if (file === undefined) {
return;
}
if (!file.type.startsWith('image/')) {
setError(new Error(t('settings.channel.background.notImage')));
return;
}
if (file.size > BACKGROUND_MAX_BYTES) {
setError(new Error(t('settings.channel.background.tooLarge')));
return;
}
upload.mutate(file);
};
const pending = upload.isPending || remove.isPending;
return (
<SettingsSection
title={t('settings.channel.background.title')}
description={t('settings.channel.background.description')}
>
<div className="mt-4 flex flex-col gap-3" data-testid="channel-background">
{backgroundFileId === undefined || backgroundFileId === '' ? (
<p className="text-sm text-fg-muted" data-testid="channel-background-empty">
{t('settings.channel.background.empty')}
</p>
) : (
<div className="relative h-32 w-full max-w-md overflow-hidden rounded-[var(--radius-md)] border border-border/50">
<AnimatedImage
src={fileContentUrl(backgroundFileId)}
className="h-full w-full object-cover"
/>
</div>
)}
<div className="flex items-center gap-2">
<Button
data-testid="channel-background-upload"
disabled={pending}
onClick={() => inputRef.current?.click()}
>
{t('settings.channel.background.upload')}
</Button>
{backgroundFileId === undefined || backgroundFileId === '' ? null : (
<Button
variant="ghost"
className="border border-danger/50 text-danger"
data-testid="channel-background-remove"
disabled={pending}
onClick={() => remove.mutate()}
>
{t('settings.channel.background.remove')}
</Button>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
aria-label={t('settings.channel.background.title')}
data-testid="channel-background-input"
className="sr-only"
onChange={(event) => {
pick(event.target.files?.[0]);
event.target.value = '';
}}
/>
</div>
{error === null ? null : <ErrorNotice error={error} />}
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
{remove.isError ? <ErrorNotice error={remove.error} /> : null}
</div>
</SettingsSection>
);
}
@@ -12,6 +12,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
import { canManageWebhooks } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelBackgroundSection } from '@/pages/settings/ChannelBackgroundSection';
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
@@ -110,6 +111,12 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
currentUser.data?.id,
isInstanceAdmin,
);
// Фон комнаты — отдельное право MANAGE_CHANNEL_BACKGROUND (AGENT.md 7.5).
const canManageBackground =
isInstanceAdmin ||
guild.owner_id === currentUser.data?.id ||
(guild.my_permissions ?? []).includes('ADMINISTRATOR') ||
(guild.my_permissions ?? []).includes('MANAGE_CHANNEL_BACKGROUND');
const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null;
return (
@@ -146,6 +153,12 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
{active === null ? null : (
<>
<ChannelBackgroundSection
key={`${active.id}-background`}
channelId={active.id}
backgroundFileId={active.background_file_id}
canManage={canManageBackground}
/>
<ChannelWebhooksSection
key={`${active.id}-webhooks`}
channelId={active.id}