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:
+37
-1
@@ -1,4 +1,4 @@
|
||||
import { ApiError, isApiErrorPayload, parsePayload } from './client';
|
||||
import { ApiError, isApiErrorPayload, parsePayload, request } from './client';
|
||||
|
||||
/**
|
||||
* Загрузка файлов в комнату (AGENT.md §7.7).
|
||||
@@ -63,3 +63,39 @@ export async function uploadChannelFile(
|
||||
export function fileContentUrl(fileId: string): string {
|
||||
return `/files/${encodeURIComponent(fileId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /channels/{id}/background` — фон комнаты (multipart, право
|
||||
* MANAGE_CHANNEL_BACKGROUND). Ответ — обновлённая комната.
|
||||
*/
|
||||
export async function uploadChannelBackground(
|
||||
channelId: string,
|
||||
file: File,
|
||||
): Promise<{ background_file_id?: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const response = await fetch(`/api/v1/channels/${encodeURIComponent(channelId)}/background`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: form,
|
||||
});
|
||||
const payload = parsePayload(await response.text(), response.headers.get('Content-Type'));
|
||||
if (!response.ok) {
|
||||
const envelope =
|
||||
typeof payload === 'object' && payload !== null
|
||||
? (payload as { error?: unknown }).error
|
||||
: undefined;
|
||||
if (isApiErrorPayload(envelope)) {
|
||||
throw new ApiError(response.status, envelope.code, envelope.message, envelope.details);
|
||||
}
|
||||
throw new ApiError(response.status, 'internal.error', 'unexpected response');
|
||||
}
|
||||
const channel = (payload as { channel?: { background_file_id?: string } } | null)?.channel;
|
||||
return channel ?? {};
|
||||
}
|
||||
|
||||
/** `DELETE /channels/{id}/background` — убрать фон комнаты. */
|
||||
export async function deleteChannelBackground(channelId: string): Promise<void> {
|
||||
await request(`/channels/${encodeURIComponent(channelId)}/background`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -327,6 +327,11 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
||||
if (typeof record['can_view'] === 'boolean') {
|
||||
channel.can_view = record['can_view'];
|
||||
}
|
||||
// Фон комнаты: приходит в READY и CHANNEL_UPDATE (AGENT.md 7.5).
|
||||
const backgroundFileId = asString(record['background_file_id']);
|
||||
if (backgroundFileId !== undefined) {
|
||||
channel.background_file_id = backgroundFileId;
|
||||
}
|
||||
// Личная беседа: собеседник и последнее сообщение (READY и CHANNEL_*).
|
||||
const recipientId = asString(record['recipient_id']);
|
||||
const iconFileId = asString(record['icon_file_id']);
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface Role {
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
/** Фон комнаты: файл-картинка за лентой сообщений (AGENT.md 7.5). */
|
||||
background_file_id?: string;
|
||||
guild_id?: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useReducedMotion, useStaticFrame } from '@/lib/reducedMotion';
|
||||
|
||||
/**
|
||||
* Картинка, которая при «уменьшить движение» показывает статичный первый кадр
|
||||
* вместо анимации (AGENT.md 7.2, 7.7). Используется для фонов, рамок, аватаров
|
||||
* и баннеров — везде, где может лежать GIF или анимированный WebP.
|
||||
*/
|
||||
|
||||
interface AnimatedImageProps {
|
||||
src: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
}
|
||||
|
||||
/** Картинка, уважающая настройку «уменьшить движение». */
|
||||
export function AnimatedImage({ src, alt = '', className, loading }: AnimatedImageProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const frame = useStaticFrame(src, reduced);
|
||||
return (
|
||||
<img
|
||||
src={frame ?? src}
|
||||
alt={alt}
|
||||
{...(className === undefined ? {} : { className })}
|
||||
{...(loading === undefined ? {} : { loading })}
|
||||
data-animated={frame !== undefined && frame !== src ? 'static' : 'animated'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1106,7 +1106,14 @@
|
||||
"limit": "{{count}} used",
|
||||
"deleteConfirm": "Delete the webhook? The link stops working, messages already sent stay.",
|
||||
"stepUpBody": "Creating a webhook requires confirming your identity: enter your password{{totp}}."
|
||||
}
|
||||
},
|
||||
"title": "Channel background",
|
||||
"description": "An image behind the message feed. Animated GIF and WebP keep their animation; with the system reduce-motion setting the first frame is shown.",
|
||||
"empty": "No background set.",
|
||||
"upload": "Upload background",
|
||||
"remove": "Remove background",
|
||||
"notImage": "An image file is required",
|
||||
"tooLarge": "File is larger than 5 MB"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1106,7 +1106,14 @@
|
||||
"limit": "занято {{count}}",
|
||||
"deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.",
|
||||
"stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}."
|
||||
}
|
||||
},
|
||||
"title": "Фон комнаты",
|
||||
"description": "Картинка за лентой сообщений. Анимированные GIF и WebP сохраняют анимацию; при системной настройке «уменьшить движение» показывается первый кадр.",
|
||||
"empty": "Фон не задан.",
|
||||
"upload": "Загрузить фон",
|
||||
"remove": "Убрать фон",
|
||||
"notImage": "Нужен файл изображения",
|
||||
"tooLarge": "Файл больше 5 МБ"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Анимированные картинки и настройка «уменьшить движение» (AGENT.md 7.2, 7.7):
|
||||
* если пользователь просил меньше анимации, показываем статичный первый кадр.
|
||||
* Кадр рисуется на canvas — серверную генерацию превью ждать не нужно.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Запрос «уменьшить движение» из системных настроек. */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** Подписка на изменение системной настройки движения. */
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(prefersReducedMotion);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const listener = (event: MediaQueryListEvent): void => setReduced(event.matches);
|
||||
query.addEventListener('change', listener);
|
||||
return () => query.removeEventListener('change', listener);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/** Подпись canvas: первый кадр картинки в data URL. */
|
||||
function firstFrameUrl(image: HTMLImageElement): string | null {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
if (canvas.width === 0 || canvas.height === 0) {
|
||||
return null;
|
||||
}
|
||||
const context = canvas.getContext('2d');
|
||||
if (context === null) {
|
||||
return null;
|
||||
}
|
||||
context.drawImage(image, 0, 0);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
// Чужая картинка без CORS: canvas «испорчен», статичный кадр не сделать.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useStaticFrame возвращает ссылку на первый кадр картинки, когда анимация
|
||||
* нежелательна; иначе — исходную ссылку. Кадр привязан к источнику, поэтому
|
||||
* смена картинки не показывает чужой кадр.
|
||||
*/
|
||||
export function useStaticFrame(src: string | undefined, reduced: boolean): string | undefined {
|
||||
const [frame, setFrame] = useState<{ src: string; url: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (src === undefined || src === '' || !reduced) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => {
|
||||
const url = firstFrameUrl(image);
|
||||
if (!cancelled && url !== null) {
|
||||
setFrame({ src, url });
|
||||
}
|
||||
};
|
||||
image.src = src;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, reduced]);
|
||||
|
||||
if (!reduced || src === undefined || frame === null || frame.src !== src) {
|
||||
return src;
|
||||
}
|
||||
return frame.url;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { ChatView } from '@/components/chat/ChatView';
|
||||
import { PinsPanel } from '@/components/chat/PinsPanel';
|
||||
import { SearchPanel } from '@/components/chat/SearchPanel';
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
||||
import { canManageMessages, canManageWebhooks, canSendMessages } from '@/lib/identity';
|
||||
@@ -89,6 +91,17 @@ export default function GuildView() {
|
||||
|
||||
return (
|
||||
<section id="app-content" className="absolute inset-0 flex flex-col">
|
||||
{/* Фон комнаты: картинка за лентой, затемнение — для читаемости текста. */}
|
||||
{channel.background_file_id === undefined || channel.background_file_id === '' ? null : (
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 overflow-hidden">
|
||||
<AnimatedImage
|
||||
src={fileContentUrl(channel.background_file_id)}
|
||||
className="h-full w-full object-cover opacity-40"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute inset-0 bg-bg/60" data-testid="channel-background-overlay" />
|
||||
</div>
|
||||
)}
|
||||
<header
|
||||
data-testid="channel-header"
|
||||
className="flex items-center gap-2 border-b border-border/40 px-4 py-3"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user