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:
@@ -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;
|
||||||
@@ -756,3 +756,115 @@ func optionalString(value *string) string {
|
|||||||
}
|
}
|
||||||
return *value
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
|
|||||||
file.Purpose == "sound" || file.Purpose == "webhook_avatar" {
|
file.Purpose == "sound" || file.Purpose == "webhook_avatar" {
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
|
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
|
||||||
if file.ChannelID != nil {
|
if file.ChannelID != nil {
|
||||||
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
|
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type channelPayload struct {
|
|||||||
CanView bool `json:"can_view"`
|
CanView bool `json:"can_view"`
|
||||||
CanSend bool `json:"can_send"`
|
CanSend bool `json:"can_send"`
|
||||||
CanConnect bool `json:"can_connect"`
|
CanConnect bool `json:"can_connect"`
|
||||||
|
// BackgroundFileID — фон комнаты (AGENT.md 7.5, 7.7).
|
||||||
|
BackgroundFileID string `json:"background_file_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type rolePayload struct {
|
type rolePayload struct {
|
||||||
@@ -97,6 +99,9 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved) cha
|
|||||||
if channel.ParentID != nil {
|
if channel.ParentID != nil {
|
||||||
payload.ParentID = formatSnowflake(*channel.ParentID)
|
payload.ParentID = formatSnowflake(*channel.ParentID)
|
||||||
}
|
}
|
||||||
|
if channel.BackgroundFileID != nil {
|
||||||
|
payload.BackgroundFileID = formatSnowflake(*channel.BackgroundFileID)
|
||||||
|
}
|
||||||
if resolved != nil {
|
if resolved != nil {
|
||||||
payload.CanView = resolved.CanViewChannel()
|
payload.CanView = resolved.CanViewChannel()
|
||||||
payload.CanSend = resolved.Can(permissions.SendMessages)
|
payload.CanSend = resolved.Can(permissions.SendMessages)
|
||||||
|
|||||||
@@ -393,3 +393,88 @@ func TestFriendsListCarriesCosmetics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_ = guildID
|
_ = 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerSoundsRoutes(s.api, apiRouter)
|
s.registerSoundsRoutes(s.api, apiRouter)
|
||||||
s.registerWebhookRoutes(s.api, apiRouter)
|
s.registerWebhookRoutes(s.api, apiRouter)
|
||||||
s.registerCosmeticRoutes(s.api, apiRouter)
|
s.registerCosmeticRoutes(s.api, apiRouter)
|
||||||
|
s.registerChannelBackgroundRoutes(apiRouter)
|
||||||
s.registerVoiceWebhook(apiRouter)
|
s.registerVoiceWebhook(apiRouter)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type Channel struct {
|
|||||||
SlowmodeSeconds int
|
SlowmodeSeconds int
|
||||||
NSFW bool
|
NSFW bool
|
||||||
Background string
|
Background string
|
||||||
|
// BackgroundFileID — фон комнаты: файл-картинка (AGENT.md 7.5).
|
||||||
|
BackgroundFileID *uint64
|
||||||
VoiceStatus string
|
VoiceStatus string
|
||||||
UserLimit int
|
UserLimit int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -54,7 +56,7 @@ type CreateChannelParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
|
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) {
|
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
|
||||||
if params.ID == 0 {
|
if params.ID == 0 {
|
||||||
@@ -95,6 +97,9 @@ type UpdateChannelParams struct {
|
|||||||
SlowmodeSeconds *int
|
SlowmodeSeconds *int
|
||||||
NSFW *bool
|
NSFW *bool
|
||||||
Background *string
|
Background *string
|
||||||
|
// BackgroundFileID задаёт новый фон, ClearBackground снимает его.
|
||||||
|
BackgroundFileID *uint64
|
||||||
|
ClearBackground bool
|
||||||
VoiceStatus *string
|
VoiceStatus *string
|
||||||
UserLimit *int
|
UserLimit *int
|
||||||
}
|
}
|
||||||
@@ -133,6 +138,13 @@ func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChann
|
|||||||
sets = append(sets, "background = ?")
|
sets = append(sets, "background = ?")
|
||||||
args = append(args, nullableString(*params.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 {
|
if params.VoiceStatus != nil {
|
||||||
sets = append(sets, "voice_status = ?")
|
sets = append(sets, "voice_status = ?")
|
||||||
args = append(args, *params.VoiceStatus)
|
args = append(args, *params.VoiceStatus)
|
||||||
@@ -212,11 +224,12 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
|||||||
parentID sql.NullInt64
|
parentID sql.NullInt64
|
||||||
nsfw int
|
nsfw int
|
||||||
background sql.NullString
|
background sql.NullString
|
||||||
|
backgroundID sql.NullInt64
|
||||||
createdAt string
|
createdAt string
|
||||||
)
|
)
|
||||||
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
|
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
|
||||||
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
|
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
|
||||||
&channel.VoiceStatus, &channel.UserLimit, &createdAt)
|
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
}
|
}
|
||||||
@@ -226,6 +239,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
|||||||
if background.Valid {
|
if background.Valid {
|
||||||
channel.Background = background.String
|
channel.Background = background.String
|
||||||
}
|
}
|
||||||
|
channel.BackgroundFileID = optionalID(backgroundID)
|
||||||
channel.CreatedAt = parseTimestamp(createdAt)
|
channel.CreatedAt = parseTimestamp(createdAt)
|
||||||
return &channel, nil
|
return &channel, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ApiError, isApiErrorPayload, parsePayload } from './client';
|
import { ApiError, isApiErrorPayload, parsePayload, request } from './client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Загрузка файлов в комнату (AGENT.md §7.7).
|
* Загрузка файлов в комнату (AGENT.md §7.7).
|
||||||
@@ -63,3 +63,39 @@ export async function uploadChannelFile(
|
|||||||
export function fileContentUrl(fileId: string): string {
|
export function fileContentUrl(fileId: string): string {
|
||||||
return `/files/${encodeURIComponent(fileId)}`;
|
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') {
|
if (typeof record['can_view'] === 'boolean') {
|
||||||
channel.can_view = record['can_view'];
|
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_*).
|
// Личная беседа: собеседник и последнее сообщение (READY и CHANNEL_*).
|
||||||
const recipientId = asString(record['recipient_id']);
|
const recipientId = asString(record['recipient_id']);
|
||||||
const iconFileId = asString(record['icon_file_id']);
|
const iconFileId = asString(record['icon_file_id']);
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export interface Role {
|
|||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Фон комнаты: файл-картинка за лентой сообщений (AGENT.md 7.5). */
|
||||||
|
background_file_id?: string;
|
||||||
guild_id?: string;
|
guild_id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: ChannelType;
|
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",
|
"limit": "{{count}} used",
|
||||||
"deleteConfirm": "Delete the webhook? The link stops working, messages already sent stay.",
|
"deleteConfirm": "Delete the webhook? The link stops working, messages already sent stay.",
|
||||||
"stepUpBody": "Creating a webhook requires confirming your identity: enter your password{{totp}}."
|
"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}}",
|
"limit": "занято {{count}}",
|
||||||
"deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.",
|
"deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.",
|
||||||
"stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}."
|
"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 { PinsPanel } from '@/components/chat/PinsPanel';
|
||||||
import { SearchPanel } from '@/components/chat/SearchPanel';
|
import { SearchPanel } from '@/components/chat/SearchPanel';
|
||||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
|
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
||||||
|
import { fileContentUrl } from '@/api/files';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
||||||
import { canManageMessages, canManageWebhooks, canSendMessages } from '@/lib/identity';
|
import { canManageMessages, canManageWebhooks, canSendMessages } from '@/lib/identity';
|
||||||
@@ -89,6 +91,17 @@ export default function GuildView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="app-content" className="absolute inset-0 flex flex-col">
|
<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
|
<header
|
||||||
data-testid="channel-header"
|
data-testid="channel-header"
|
||||||
className="flex items-center gap-2 border-b border-border/40 px-4 py-3"
|
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 { canManageWebhooks } from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { ChannelBackgroundSection } from '@/pages/settings/ChannelBackgroundSection';
|
||||||
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
|
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
|
||||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||||
|
|
||||||
@@ -110,6 +111,12 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
|
|||||||
currentUser.data?.id,
|
currentUser.data?.id,
|
||||||
isInstanceAdmin,
|
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;
|
const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,6 +153,12 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
|
|||||||
|
|
||||||
{active === null ? null : (
|
{active === null ? null : (
|
||||||
<>
|
<>
|
||||||
|
<ChannelBackgroundSection
|
||||||
|
key={`${active.id}-background`}
|
||||||
|
channelId={active.id}
|
||||||
|
backgroundFileId={active.background_file_id}
|
||||||
|
canManage={canManageBackground}
|
||||||
|
/>
|
||||||
<ChannelWebhooksSection
|
<ChannelWebhooksSection
|
||||||
key={`${active.id}-webhooks`}
|
key={`${active.id}-webhooks`}
|
||||||
channelId={active.id}
|
channelId={active.id}
|
||||||
|
|||||||
@@ -257,6 +257,8 @@ interface SnapshotGuildInput {
|
|||||||
parent_id?: string;
|
parent_id?: string;
|
||||||
can_send?: boolean;
|
can_send?: boolean;
|
||||||
can_view?: boolean;
|
can_view?: boolean;
|
||||||
|
/** Фон комнаты: файл-картинка за лентой (AGENT.md 7.5). */
|
||||||
|
background_file_id?: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +306,9 @@ export function makeReadySnapshot(
|
|||||||
...(channel.parent_id === undefined ? {} : { parent_id: channel.parent_id }),
|
...(channel.parent_id === undefined ? {} : { parent_id: channel.parent_id }),
|
||||||
...(channel.can_send === undefined ? {} : { can_send: channel.can_send }),
|
...(channel.can_send === undefined ? {} : { can_send: channel.can_send }),
|
||||||
...(channel.can_view === undefined ? {} : { can_view: channel.can_view }),
|
...(channel.can_view === undefined ? {} : { can_view: channel.can_view }),
|
||||||
|
...(channel.background_file_id === undefined
|
||||||
|
? {}
|
||||||
|
: { background_file_id: channel.background_file_id }),
|
||||||
})),
|
})),
|
||||||
roles: [],
|
roles: [],
|
||||||
member_ids: ['user-1'],
|
member_ids: ['user-1'],
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
||||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
|
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
|
||||||
import {
|
import {
|
||||||
bodyOf,
|
bodyOf,
|
||||||
findRequest,
|
findRequest,
|
||||||
installFetch,
|
installFetch,
|
||||||
|
installGatewaySocket,
|
||||||
json,
|
json,
|
||||||
|
makeReadySnapshot,
|
||||||
makeUser,
|
makeUser,
|
||||||
|
messagesRoutes,
|
||||||
recordedRequests,
|
recordedRequests,
|
||||||
renderApp,
|
renderApp,
|
||||||
resetStores,
|
resetStores,
|
||||||
@@ -383,3 +387,114 @@ describe('шаблоны серверов', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(<AnimatedImage src="/files/bg-1" />);
|
||||||
|
const image = container.querySelector('img');
|
||||||
|
// Первый кадр рисуется из картинки: пока она не загрузилась, показываем
|
||||||
|
// исходную ссылку и помечаем её как анимированную.
|
||||||
|
expect(image?.getAttribute('src')).toBe('/files/bg-1');
|
||||||
|
expect(image?.getAttribute('data-animated')).toBe('animated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user