From 0f0060e8a43fdc8d1c9ec64d95fab115c3cbd957 Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 18:08:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=D1=84=D0=BE=D0=BD=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BD=D0=B0=D1=82=D1=8B=20=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=87=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20=D0=BF=D1=80=D0=B8=20reduced-motion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - миграция 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) --- .../migrations/00014_channel_background.sql | 7 + internal/server/api_cosmetics.go | 112 +++++++++++ internal/server/api_files.go | 1 + internal/server/api_guilds.go | 5 + internal/server/cosmetics_test.go | 85 +++++++++ internal/server/server.go | 1 + internal/store/channels.go | 40 ++-- web/src/api/files.ts | 38 +++- web/src/api/gatewayEvents.ts | 5 + web/src/api/types.ts | 2 + web/src/components/ui/AnimatedImage.tsx | 29 +++ web/src/i18n/locales/en.json | 9 +- web/src/i18n/locales/ru.json | 9 +- web/src/lib/reducedMotion.ts | 85 +++++++++ web/src/pages/app/GuildView.tsx | 13 ++ .../settings/ChannelBackgroundSection.tsx | 175 ++++++++++++++++++ .../pages/settings/ChannelSettingsPage.tsx | 13 ++ web/tests/helpers.tsx | 5 + web/tests/profileCosmetics.test.tsx | 115 ++++++++++++ 19 files changed, 733 insertions(+), 16 deletions(-) create mode 100644 internal/database/migrations/00014_channel_background.sql create mode 100644 web/src/components/ui/AnimatedImage.tsx create mode 100644 web/src/lib/reducedMotion.ts create mode 100644 web/src/pages/settings/ChannelBackgroundSection.tsx diff --git a/internal/database/migrations/00014_channel_background.sql b/internal/database/migrations/00014_channel_background.sql new file mode 100644 index 0000000..4f8cd5d --- /dev/null +++ b/internal/database/migrations/00014_channel_background.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Фон комнаты (AGENT.md 7.5, 7.7): картинка за лентой сообщений. Хранится +-- обычной загрузкой, поэтому анимированные GIF/APNG/WebP не перекодируются. +ALTER TABLE channels ADD COLUMN background_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL; + +-- +goose Down +ALTER TABLE channels DROP COLUMN background_file_id; diff --git a/internal/server/api_cosmetics.go b/internal/server/api_cosmetics.go index 7e045bc..adfa00e 100644 --- a/internal/server/api_cosmetics.go +++ b/internal/server/api_cosmetics.go @@ -756,3 +756,115 @@ func optionalString(value *string) string { } return *value } + +// maxChannelBackgroundSize — предел размера фона комнаты (AGENT.md 7.7). +const maxChannelBackgroundSize = 5 << 20 + +// registerChannelBackgroundRoutes описывает фон комнаты (AGENT.md 7.5, 7.7): +// картинка за лентой сообщений, право MANAGE_CHANNEL_BACKGROUND. +func (s *Server) registerChannelBackgroundRoutes(router chi.Router) { + router.Post("/channels/{channel_id}/background", s.handleChannelBackgroundUpload) + router.Delete("/channels/{channel_id}/background", s.handleChannelBackgroundDelete) +} + +func (s *Server) handleChannelBackgroundUpload(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + channelID, _, _, err := s.requireChannelPermission(ctx, + chi.URLParam(r, "channel_id"), user, permissions.ManageChannelBackground) + if err != nil { + writeHumaAPIError(w, err) + return + } + file, header, err := r.FormFile("file") + if err != nil { + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) + return + } + defer func() { _ = file.Close() }() + if header.Size > maxChannelBackgroundSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large") + return + } + data, err := io.ReadAll(io.LimitReader(file, maxChannelBackgroundSize+1)) + if err != nil || int64(len(data)) > maxChannelBackgroundSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "background is too large") + return + } + contentType := header.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + contentType = http.DetectContentType(data) + } + if !strings.HasPrefix(contentType, "image/") { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "background must be an image") + return + } + + channel, err := s.store.GetChannel(ctx, channelID) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + // Старый фон удаляем: загрузки не должны копиться (AGENT.md 7.7). + if previous := channel.BackgroundFileID; previous != nil && *previous != 0 { + s.deleteStoredFile(ctx, *previous) + } + stored, err := s.saveUpload(ctx, store.File{ + UploaderID: &user.ID, + GuildID: channel.GuildID, + ChannelID: &channelID, + Filename: sanitizeFilename(header.Filename), + ContentType: contentType, + Purpose: "channel_background", + }, bytes.NewReader(data)) + if err != nil { + writeHumaAPIError(w, err) + return + } + fileID := stored.ID + updated, err := s.store.UpdateChannel(ctx, channelID, store.UpdateChannelParams{BackgroundFileID: &fileID}) + if err != nil { + s.deleteStoredFile(ctx, stored.ID) + writeHumaAPIError(w, humaError(err)) + return + } + if channel.GuildID != nil { + s.invalidateGuild(*channel.GuildID) + s.recordAudit(ctx, user, *channel.GuildID, "channel.background", "channel", &channelID, "") + } + payload := channelFromStore(*updated, nil) + s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload) + httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload}) +} + +func (s *Server) handleChannelBackgroundDelete(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + channelID, _, channel, err := s.requireChannelPermission(ctx, + chi.URLParam(r, "channel_id"), user, permissions.ManageChannelBackground) + if err != nil { + writeHumaAPIError(w, err) + return + } + if previous := channel.BackgroundFileID; previous != nil && *previous != 0 { + s.deleteStoredFile(ctx, *previous) + } + updated, err := s.store.UpdateChannel(ctx, channelID, store.UpdateChannelParams{ClearBackground: true}) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + if channel.GuildID != nil { + s.invalidateGuild(*channel.GuildID) + s.recordAudit(ctx, user, *channel.GuildID, "channel.background_remove", "channel", &channelID, "") + } + payload := channelFromStore(*updated, nil) + s.dispatchChannelEvent(ctx, channelID, "CHANNEL_UPDATE", payload) + httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": payload}) +} diff --git a/internal/server/api_files.go b/internal/server/api_files.go index b628f72..f968dee 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -304,6 +304,7 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user * file.Purpose == "sound" || file.Purpose == "webhook_avatar" { return file, nil } + // Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу). if file.ChannelID != nil { if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil { return nil, err diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index c8e793f..35d9b02 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -30,6 +30,8 @@ type channelPayload struct { CanView bool `json:"can_view"` CanSend bool `json:"can_send"` CanConnect bool `json:"can_connect"` + // BackgroundFileID — фон комнаты (AGENT.md 7.5, 7.7). + BackgroundFileID string `json:"background_file_id,omitempty"` } type rolePayload struct { @@ -97,6 +99,9 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved) cha if channel.ParentID != nil { payload.ParentID = formatSnowflake(*channel.ParentID) } + if channel.BackgroundFileID != nil { + payload.BackgroundFileID = formatSnowflake(*channel.BackgroundFileID) + } if resolved != nil { payload.CanView = resolved.CanViewChannel() payload.CanSend = resolved.Can(permissions.SendMessages) diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index 0ed8b71..d8ee1fe 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -393,3 +393,88 @@ func TestFriendsListCarriesCosmetics(t *testing.T) { } _ = guildID } + +func TestChannelBackgroundUploadAndDelete(t *testing.T) { + srv, owner, member, guildID, _ := cosmeticsFixture(t) + channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner) + channelID := decodeResponse[struct { + Channels []struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"channels"` + }](t, channels).Channels[0].ID + + // Загрузка фона владельцем: multipart, картинка. + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "bg.png") + if err != nil { + t.Fatalf("multipart: %v", err) + } + if _, err := part.Write(pngBytes()); err != nil { + t.Fatalf("multipart write: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("multipart close: %v", err) + } + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/channels/"+channelID+"/background", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + request.AddCookie(owner) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, request) + if rec.Code != http.StatusOK { + t.Fatalf("загрузка фона = %d (%s)", rec.Code, rec.Body.String()) + } + uploaded := decodeResponse[struct { + Channel struct { + BackgroundFileID string `json:"background_file_id"` + } `json:"channel"` + }](t, rec) + if uploaded.Channel.BackgroundFileID == "" { + t.Fatalf("фон не применился: %s", rec.Body.String()) + } + + // Участник без MANAGE_CHANNEL_BACKGROUND фон не меняет. + request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/channels/"+channelID+"/background", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + request.AddCookie(member) + denied := httptest.NewRecorder() + srv.Handler().ServeHTTP(denied, request) + if denied.Code != http.StatusForbidden { + t.Fatalf("фон участником = %d, ожидался 403 (%s)", denied.Code, denied.Body.String()) + } + + // Фон виден в списке комнат сервера. + list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", member) + payload := decodeResponse[struct { + Channels []struct { + ID string `json:"id"` + BackgroundFileID string `json:"background_file_id"` + } `json:"channels"` + }](t, list) + found := false + for _, channel := range payload.Channels { + if channel.ID == channelID && channel.BackgroundFileID == uploaded.Channel.BackgroundFileID { + found = true + } + } + if !found { + t.Fatalf("фон не виден участнику: %s", list.Body.String()) + } + + // Снятие фона. + removed := doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/background", "", owner) + if removed.Code != http.StatusOK { + t.Fatalf("снятие фона = %d (%s)", removed.Code, removed.Body.String()) + } + after := decodeResponse[struct { + Channel struct { + BackgroundFileID string `json:"background_file_id"` + } `json:"channel"` + }](t, removed) + if after.Channel.BackgroundFileID != "" { + t.Fatalf("фон не снят: %s", removed.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 73810db..078ce22 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -154,6 +154,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerSoundsRoutes(s.api, apiRouter) s.registerWebhookRoutes(s.api, apiRouter) s.registerCosmeticRoutes(s.api, apiRouter) + s.registerChannelBackgroundRoutes(apiRouter) s.registerVoiceWebhook(apiRouter) } apiRouter.Get("/openapi.json", s.handleOpenAPI) diff --git a/internal/store/channels.go b/internal/store/channels.go index f693aa0..89f210d 100644 --- a/internal/store/channels.go +++ b/internal/store/channels.go @@ -27,9 +27,11 @@ type Channel struct { SlowmodeSeconds int NSFW bool Background string - VoiceStatus string - UserLimit int - CreatedAt time.Time + // BackgroundFileID — фон комнаты: файл-картинка (AGENT.md 7.5). + BackgroundFileID *uint64 + VoiceStatus string + UserLimit int + CreatedAt time.Time } type ChannelOverride struct { @@ -54,7 +56,7 @@ type CreateChannelParams struct { } const channelColumns = `id, guild_id, type, name, description, position, parent_id, - slowmode_seconds, nsfw, background, voice_status, user_limit, created_at` + slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, created_at` func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) { if params.ID == 0 { @@ -95,8 +97,11 @@ type UpdateChannelParams struct { SlowmodeSeconds *int NSFW *bool Background *string - VoiceStatus *string - UserLimit *int + // BackgroundFileID задаёт новый фон, ClearBackground снимает его. + BackgroundFileID *uint64 + ClearBackground bool + VoiceStatus *string + UserLimit *int } func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChannelParams) (*Channel, error) { @@ -133,6 +138,13 @@ func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChann sets = append(sets, "background = ?") args = append(args, nullableString(*params.Background)) } + if params.BackgroundFileID != nil { + sets = append(sets, "background_file_id = ?") + args = append(args, int64(*params.BackgroundFileID)) + } + if params.ClearBackground { + sets = append(sets, "background_file_id = NULL") + } if params.VoiceStatus != nil { sets = append(sets, "voice_status = ?") args = append(args, *params.VoiceStatus) @@ -207,16 +219,17 @@ func (s *Store) ListChannelOverrides(ctx context.Context, channelID uint64) ([]C func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) { var ( - channel Channel - guildID sql.NullInt64 - parentID sql.NullInt64 - nsfw int - background sql.NullString - createdAt string + channel Channel + guildID sql.NullInt64 + parentID sql.NullInt64 + nsfw int + background sql.NullString + backgroundID sql.NullInt64 + createdAt string ) err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description, &channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background, - &channel.VoiceStatus, &channel.UserLimit, &createdAt) + &backgroundID, &channel.VoiceStatus, &channel.UserLimit, &createdAt) if err != nil { return nil, mapError(err) } @@ -226,6 +239,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) { if background.Valid { channel.Background = background.String } + channel.BackgroundFileID = optionalID(backgroundID) channel.CreatedAt = parseTimestamp(createdAt) return &channel, nil } diff --git a/web/src/api/files.ts b/web/src/api/files.ts index 928aea5..7d95c1d 100644 --- a/web/src/api/files.ts +++ b/web/src/api/files.ts @@ -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 { + await request(`/channels/${encodeURIComponent(channelId)}/background`, { method: 'DELETE' }); +} diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index 41ad44b..7c21f73 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -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']); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 99fba57..902f90a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/components/ui/AnimatedImage.tsx b/web/src/components/ui/AnimatedImage.tsx new file mode 100644 index 0000000..3b4a749 --- /dev/null +++ b/web/src/components/ui/AnimatedImage.tsx @@ -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 ( + {alt} + ); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6b54862..77b692e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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" } } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index cb94441..7dab358 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1106,7 +1106,14 @@ "limit": "занято {{count}}", "deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.", "stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}." - } + }, + "title": "Фон комнаты", + "description": "Картинка за лентой сообщений. Анимированные GIF и WebP сохраняют анимацию; при системной настройке «уменьшить движение» показывается первый кадр.", + "empty": "Фон не задан.", + "upload": "Загрузить фон", + "remove": "Убрать фон", + "notImage": "Нужен файл изображения", + "tooLarge": "Файл больше 5 МБ" } } } diff --git a/web/src/lib/reducedMotion.ts b/web/src/lib/reducedMotion.ts new file mode 100644 index 0000000..b556fd5 --- /dev/null +++ b/web/src/lib/reducedMotion.ts @@ -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; +} diff --git a/web/src/pages/app/GuildView.tsx b/web/src/pages/app/GuildView.tsx index 9569cbb..8066ba4 100644 --- a/web/src/pages/app/GuildView.tsx +++ b/web/src/pages/app/GuildView.tsx @@ -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 (
+ {/* Фон комнаты: картинка за лентой, затемнение — для читаемости текста. */} + {channel.background_file_id === undefined || channel.background_file_id === '' ? null : ( + + )}
(null); + const [error, setError] = useState(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 ( + +
+ {backgroundFileId === undefined || backgroundFileId === '' ? ( +

+ {t('settings.channel.background.empty')} +

+ ) : ( +
+ +
+ )} + +
+ + {backgroundFileId === undefined || backgroundFileId === '' ? null : ( + + )} + { + pick(event.target.files?.[0]); + event.target.value = ''; + }} + /> +
+ + {error === null ? null : } + {upload.isError ? : null} + {remove.isError ? : null} +
+
+ ); +} diff --git a/web/src/pages/settings/ChannelSettingsPage.tsx b/web/src/pages/settings/ChannelSettingsPage.tsx index 7e4f45a..117e9ce 100644 --- a/web/src/pages/settings/ChannelSettingsPage.tsx +++ b/web/src/pages/settings/ChannelSettingsPage.tsx @@ -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 : ( <> + { }); }); }); + +describe('фон комнаты', () => { + it('показывает фон в ленте и позволяет его сменить', async () => { + const channel = { + id: 'c-1', + name: 'общий', + type: 'text' as const, + position: 0, + background_file_id: 'bg-1', + }; + const routes: FetchRoute[] = [ + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) }, + { 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', + response: () => + json({ + guild: { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 1, + my_role_ids: [], + my_permissions: ['MANAGE_CHANNEL_BACKGROUND', 'ADMINISTRATOR'], + roles: [], + channels: [channel], + }, + }), + }, + { match: '/api/v1/channels/c-1/webhooks', response: () => json({ webhooks: [] }) }, + { + match: '/api/v1/channels/c-1/background', + method: 'POST', + response: () => json({ channel: { ...channel, background_file_id: 'bg-2' } }), + }, + { + match: '/api/v1/channels/c-1/background', + method: 'DELETE', + response: () => json({ channel: { ...channel, background_file_id: undefined } }), + }, + ...messagesRoutes('c-1', []), + ]; + const fetchMock = installFetch(routes); + const snapshot = makeReadySnapshot([ + { + id: 'g-1', + name: 'Сервер', + is_main: true, + my_permissions: ['MANAGE_CHANNEL_BACKGROUND', 'ADMINISTRATOR'], + channels: [channel], + }, + ]); + const gateway = installGatewaySocket(snapshot); + renderApp('/app/g-1/c-1'); + await gateway.greet(); + await screen.findByTestId('message-list'); + + // Фон рисуется за лентой вместе с затемнением. + const overlay = screen.getByTestId('channel-background-overlay'); + const image = overlay.parentElement?.querySelector('img'); + expect(image?.getAttribute('src')).toBe('/files/bg-1'); + + // Секция настроек комнаты: загрузка нового фона. + await userEvent.click(screen.getByTestId('channel-settings')); + const section = await screen.findByTestId('channel-background'); + await userEvent.upload( + within(section).getByTestId('channel-background-input'), + new File(['фон'], 'bg.png', { type: 'image/png' }), + ); + await waitFor(() => { + expect( + recordedRequests(fetchMock).some( + (request) => request.method === 'POST' && request.url.includes('/background'), + ), + ).toBe(true); + }); + + // Удаление фона. + await userEvent.click(within(section).getByTestId('channel-background-remove')); + await waitFor(() => { + expect( + recordedRequests(fetchMock).some( + (request) => request.method === 'DELETE' && request.url.includes('/background'), + ), + ).toBe(true); + }); + }); + + it('при «уменьшить движение» подменяет анимацию первым кадром', () => { + const listeners: ((event: MediaQueryListEvent) => void)[] = []; + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('reduced-motion'), + media: query, + addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => { + listeners.push(listener); + }, + removeEventListener: () => undefined, + })); + const { container } = render(); + const image = container.querySelector('img'); + // Первый кадр рисуется из картинки: пока она не загрузилась, показываем + // исходную ссылку и помечаем её как анимированную. + expect(image?.getAttribute('src')).toBe('/files/bg-1'); + expect(image?.getAttribute('data-animated')).toBe('animated'); + }); +});