Compare commits
3 Commits
a36ee9527b
...
b43452aa04
| Author | SHA1 | Date | |
|---|---|---|---|
| b43452aa04 | |||
| 0f0060e8a4 | |||
| 91f415e36d |
@@ -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
|
||||
}
|
||||
|
||||
// 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" {
|
||||
return file, nil
|
||||
}
|
||||
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
|
||||
if file.ChannelID != nil {
|
||||
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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 {
|
||||
@@ -60,6 +62,8 @@ type memberPayload struct {
|
||||
JoinedAt string `json:"joined_at"`
|
||||
TimeoutUntil string `json:"timeout_until,omitempty"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
// Системные бейджи (AGENT.md 7.2): назначаются только системой.
|
||||
Badges []string `json:"badges"`
|
||||
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
|
||||
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
|
||||
}
|
||||
@@ -97,6 +101,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)
|
||||
@@ -186,6 +193,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
||||
}, func(ctx context.Context, input *struct {
|
||||
Body struct {
|
||||
Name string `json:"name" minLength:"1" maxLength:"64"`
|
||||
// TemplateID — встроенный шаблон структуры (AGENT.md 7.3);
|
||||
// пусто — «Пустой».
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
}
|
||||
},
|
||||
) (*guildOutput, error) {
|
||||
@@ -197,13 +207,32 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
||||
if name == "" {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
|
||||
}
|
||||
guild, err := s.createGuild(ctx, user, name)
|
||||
guild, err := s.createGuild(ctx, user, name, input.Body.TemplateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.guildOutput(ctx, user, *guild)
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "listGuildTemplates",
|
||||
Method: http.MethodGet,
|
||||
Path: "/guild-templates",
|
||||
Summary: "Встроенные шаблоны серверов",
|
||||
Tags: []string{"Guilds"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, _ *struct{}) (*guildTemplateListOutput, error) {
|
||||
if _, _, err := requireUser(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output := &guildTemplateListOutput{}
|
||||
output.Body.Templates = make([]guildTemplatePayload, 0, len(guildTemplates))
|
||||
for _, template := range guildTemplates {
|
||||
output.Body.Templates = append(output.Body.Templates, guildTemplateFromCatalog(template))
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getGuild",
|
||||
Method: http.MethodGet,
|
||||
@@ -1389,6 +1418,10 @@ func memberFromProfile(profile store.GuildMemberProfile) memberPayload {
|
||||
IsInstanceAdmin: profile.IsInstanceAdmin,
|
||||
JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339),
|
||||
RoleIDs: make([]string, 0, len(profile.RoleIDs)),
|
||||
Badges: profile.Badges,
|
||||
}
|
||||
if payload.Badges == nil {
|
||||
payload.Badges = []string{}
|
||||
}
|
||||
if profile.AvatarFileID != nil {
|
||||
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
|
||||
@@ -1518,7 +1551,11 @@ func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uin
|
||||
}
|
||||
|
||||
// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3).
|
||||
func (s *Server) createGuild(ctx context.Context, owner *store.User, name string) (*store.Guild, error) {
|
||||
func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) {
|
||||
template, known := findGuildTemplate(templateID)
|
||||
if !known {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
|
||||
}
|
||||
settings, err := s.store.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
@@ -1552,17 +1589,16 @@ func (s *Server) createGuild(ctx context.Context, owner *store.User, name string
|
||||
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
|
||||
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
|
||||
}); err != nil {
|
||||
return nil, humaError(err)
|
||||
// Структура комнат и ролей — из выбранного шаблона (AGENT.md 7.3).
|
||||
if err := s.applyGuildTemplate(ctx, guild, template); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if guild.IsMain {
|
||||
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
}
|
||||
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "")
|
||||
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID)
|
||||
if bypassed {
|
||||
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
|
||||
}
|
||||
|
||||
@@ -393,3 +393,131 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembersCarrySystemBadges(t *testing.T) {
|
||||
srv, owner, _, guildID, _ := cosmeticsFixture(t)
|
||||
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
|
||||
ownerID := decodeResponse[struct {
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
}](t, me).User.ID
|
||||
// Бейдж назначает система (AGENT.md 7.2): в тесте ставим его напрямую.
|
||||
id, err := parseID("user_id", ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("parse user id: %v", err)
|
||||
}
|
||||
if err := srv.store.SetUserBadges(t.Context(), id, []string{"instance_admin"}); err != nil {
|
||||
t.Fatalf("set badges: %v", err)
|
||||
}
|
||||
|
||||
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", owner)
|
||||
members := decodeResponse[struct {
|
||||
Members []struct {
|
||||
UserID string `json:"user_id"`
|
||||
Badges []string `json:"badges"`
|
||||
} `json:"members"`
|
||||
}](t, list)
|
||||
|
||||
var found bool
|
||||
for _, entry := range members.Members {
|
||||
if entry.Badges == nil {
|
||||
t.Fatalf("бейджи должны быть списком, а не null")
|
||||
}
|
||||
if entry.UserID != ownerID {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !contains(entry.Badges, "instance_admin") {
|
||||
t.Fatalf("бейджи владельца = %v, ожидался instance_admin", entry.Badges)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Шаблоны серверов (AGENT.md 7.3): при создании можно выбрать встроенный
|
||||
// шаблон — структура комнат и трёх ролей без сообщений. Свои шаблоны — после
|
||||
// MVP, поэтому каталог статический.
|
||||
|
||||
// guildTemplateRole — роль, которую создаёт шаблон.
|
||||
type guildTemplateRole struct {
|
||||
Name string
|
||||
Permissions []permissions.Permission
|
||||
Position int
|
||||
// Hoist — показывать участников с этой ролью отдельной группой.
|
||||
Hoist bool
|
||||
}
|
||||
|
||||
// guildTemplateChannel — комната шаблона; `Category` задаёт категорию, внутри
|
||||
// которой она создаётся (пусто — верхний уровень).
|
||||
type guildTemplateChannel struct {
|
||||
Name string
|
||||
Type store.ChannelType
|
||||
Category string
|
||||
}
|
||||
|
||||
// guildTemplate — встроенный шаблон сервера.
|
||||
type guildTemplate struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Roles []guildTemplateRole
|
||||
Channels []guildTemplateChannel
|
||||
}
|
||||
|
||||
// guildTemplates — каталог шаблонов; первый — пустой сервер (поведение по
|
||||
// умолчанию: одна общая текстовая комната).
|
||||
var guildTemplates = []guildTemplate{
|
||||
{
|
||||
ID: "empty",
|
||||
Name: "Пустой",
|
||||
Description: "Одна общая текстовая комната и роли по умолчанию.",
|
||||
Channels: []guildTemplateChannel{
|
||||
{Name: "общий", Type: store.ChannelText},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "community",
|
||||
Name: "Сообщество",
|
||||
Description: "Информация и общение: правила, объявления, флудилка и голосовая комната.",
|
||||
Roles: []guildTemplateRole{
|
||||
{
|
||||
Name: "Модератор",
|
||||
Permissions: []permissions.Permission{
|
||||
permissions.KickMembers, permissions.TimeoutMembers, permissions.ManageMessages,
|
||||
permissions.ViewAuditLog, permissions.MuteMembers, permissions.DeafenMembers,
|
||||
permissions.MoveMembers,
|
||||
},
|
||||
Position: 50,
|
||||
Hoist: true,
|
||||
},
|
||||
},
|
||||
Channels: []guildTemplateChannel{
|
||||
{Name: "Информация", Type: store.ChannelCategory},
|
||||
{Name: "правила", Type: store.ChannelText, Category: "Информация"},
|
||||
{Name: "объявления", Type: store.ChannelText, Category: "Информация"},
|
||||
{Name: "Общение", Type: store.ChannelCategory},
|
||||
{Name: "общий", Type: store.ChannelText, Category: "Общение"},
|
||||
{Name: "флудилка", Type: store.ChannelText, Category: "Общение"},
|
||||
{Name: "Голосовой", Type: store.ChannelVoice, Category: "Общение"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "gaming",
|
||||
Name: "Игровой",
|
||||
Description: "Текстовые комнаты для сбора и две голосовые под команды.",
|
||||
Roles: []guildTemplateRole{
|
||||
{
|
||||
Name: "Игрок",
|
||||
Permissions: []permissions.Permission{
|
||||
permissions.ConnectVoice, permissions.Speak, permissions.UseSoundboard,
|
||||
permissions.AttachFiles, permissions.AddReactions, permissions.EmbedLinks,
|
||||
},
|
||||
Position: 50,
|
||||
Hoist: true,
|
||||
},
|
||||
},
|
||||
Channels: []guildTemplateChannel{
|
||||
{Name: "Текстовые", Type: store.ChannelCategory},
|
||||
{Name: "общий", Type: store.ChannelText, Category: "Текстовые"},
|
||||
{Name: "поиск-игр", Type: store.ChannelText, Category: "Текстовые"},
|
||||
{Name: "Голосовые", Type: store.ChannelCategory},
|
||||
{Name: "Игровая 1", Type: store.ChannelVoice, Category: "Голосовые"},
|
||||
{Name: "Игровая 2", Type: store.ChannelVoice, Category: "Голосовые"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// permissionSet собирает маску прав из списка.
|
||||
func permissionSet(list []permissions.Permission) permissions.Permission {
|
||||
var result permissions.Permission
|
||||
for _, permission := range list {
|
||||
result |= permission
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// findGuildTemplate ищет шаблон по идентификатору; пустой id — «Пустой».
|
||||
func findGuildTemplate(id string) (guildTemplate, bool) {
|
||||
if id == "" {
|
||||
id = guildTemplates[0].ID
|
||||
}
|
||||
for _, template := range guildTemplates {
|
||||
if template.ID == id {
|
||||
return template, true
|
||||
}
|
||||
}
|
||||
return guildTemplate{}, false
|
||||
}
|
||||
|
||||
// applyGuildTemplate создаёт роли и комнаты шаблона. Администратор и
|
||||
// «Пользователь» уже созданы SeedGuildDefaults, поэтому шаблон добавляет
|
||||
// только свои роли (AGENT.md 6.3, 7.3).
|
||||
func (s *Server) applyGuildTemplate(ctx context.Context, guild *store.Guild, template guildTemplate) error {
|
||||
existing, err := s.store.ListGuildRoles(ctx, guild.ID)
|
||||
if err != nil {
|
||||
return humaError(err)
|
||||
}
|
||||
known := make(map[string]bool, len(existing))
|
||||
for _, role := range existing {
|
||||
known[role.Name] = true
|
||||
}
|
||||
for _, role := range template.Roles {
|
||||
if known[role.Name] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID,
|
||||
Name: role.Name,
|
||||
Permissions: uint64(permissionSet(role.Permissions)),
|
||||
Position: role.Position,
|
||||
Hoist: role.Hoist,
|
||||
Mentionable: true,
|
||||
}); err != nil {
|
||||
return humaError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Категории создаются до комнат: комнаты ссылаются на них через parent_id.
|
||||
categories := map[string]uint64{}
|
||||
position := 0
|
||||
for _, channel := range template.Channels {
|
||||
parentID := uint64(0)
|
||||
if channel.Category != "" {
|
||||
id, ok := categories[channel.Category]
|
||||
if !ok {
|
||||
created, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
|
||||
GuildID: &guild.ID, Type: store.ChannelCategory,
|
||||
Name: channel.Category, Position: position,
|
||||
})
|
||||
if err != nil {
|
||||
return humaError(err)
|
||||
}
|
||||
categories[channel.Category] = created.ID
|
||||
position++
|
||||
id = created.ID
|
||||
}
|
||||
parentID = id
|
||||
}
|
||||
params := store.CreateChannelParams{
|
||||
GuildID: &guild.ID,
|
||||
Type: channel.Type,
|
||||
Name: channel.Name,
|
||||
Position: position,
|
||||
}
|
||||
if parentID != 0 && channel.Type != store.ChannelCategory {
|
||||
params.ParentID = &parentID
|
||||
}
|
||||
if _, err := s.store.CreateChannel(ctx, params); err != nil {
|
||||
return humaError(err)
|
||||
}
|
||||
position++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// guildTemplatePayload — шаблон для клиента: предпросмотр структуры.
|
||||
type guildTemplatePayload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Roles []string `json:"roles"`
|
||||
TextCount int `json:"text_channels"`
|
||||
VoiceCount int `json:"voice_channels"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
type guildTemplateListOutput struct {
|
||||
Body struct {
|
||||
Templates []guildTemplatePayload `json:"templates"`
|
||||
}
|
||||
}
|
||||
|
||||
// guildTemplateFromCatalog собирает описание шаблона для клиента.
|
||||
func guildTemplateFromCatalog(template guildTemplate) guildTemplatePayload {
|
||||
payload := guildTemplatePayload{
|
||||
ID: template.ID,
|
||||
Name: template.Name,
|
||||
Description: template.Description,
|
||||
Roles: []string{"Администратор", "Пользователь"},
|
||||
Categories: []string{},
|
||||
}
|
||||
for _, role := range template.Roles {
|
||||
payload.Roles = append(payload.Roles, role.Name)
|
||||
}
|
||||
categories := map[string]bool{}
|
||||
for _, channel := range template.Channels {
|
||||
switch channel.Type {
|
||||
case store.ChannelText:
|
||||
payload.TextCount++
|
||||
case store.ChannelVoice:
|
||||
payload.VoiceCount++
|
||||
case store.ChannelCategory:
|
||||
if !categories[channel.Name] {
|
||||
categories[channel.Name] = true
|
||||
payload.Categories = append(payload.Categories, channel.Name)
|
||||
}
|
||||
case store.ChannelDM:
|
||||
// Личных бесед в шаблонах нет.
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Тесты шаблонов серверов (AGENT.md 7.3): встроенный шаблон создаёт структуру
|
||||
// комнат и ролей без сообщений, неизвестный шаблон отклоняется.
|
||||
|
||||
func TestGuildTemplatesCatalog(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
user := registerAndLogin(t, srv, "tpl_user", "tpl@example.com")
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guild-templates", "", user)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("каталог шаблонов = %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Templates []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Roles []string `json:"roles"`
|
||||
TextCount int `json:"text_channels"`
|
||||
VoiceCount int `json:"voice_channels"`
|
||||
Categories []string `json:"categories"`
|
||||
} `json:"templates"`
|
||||
}](t, rec)
|
||||
if len(payload.Templates) != 3 {
|
||||
t.Fatalf("шаблонов %d, ожидалось 3", len(payload.Templates))
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, template := range payload.Templates {
|
||||
found[template.ID] = true
|
||||
if len(template.Roles) < 2 {
|
||||
t.Fatalf("шаблон %s без ролей: %+v", template.ID, template.Roles)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"empty", "community", "gaming"} {
|
||||
if !found[id] {
|
||||
t.Fatalf("шаблон %s отсутствует в каталоге", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuildFromTemplateCreatesStructure(t *testing.T) {
|
||||
cases := []struct {
|
||||
template string
|
||||
wantRoles []string
|
||||
wantText []string
|
||||
wantVoice []string
|
||||
wantCategory []string
|
||||
}{
|
||||
{
|
||||
template: "empty",
|
||||
wantRoles: []string{"Администратор", "Пользователь"},
|
||||
wantText: []string{"общий"},
|
||||
},
|
||||
{
|
||||
template: "community",
|
||||
wantRoles: []string{"Администратор", "Пользователь", "Модератор"},
|
||||
wantText: []string{"правила", "объявления", "общий", "флудилка"},
|
||||
wantVoice: []string{"Голосовой"},
|
||||
wantCategory: []string{"Информация", "Общение"},
|
||||
},
|
||||
{
|
||||
template: "gaming",
|
||||
wantRoles: []string{"Администратор", "Пользователь", "Игрок"},
|
||||
wantText: []string{"общий", "поиск-игр"},
|
||||
wantVoice: []string{"Игровая 1", "Игровая 2"},
|
||||
wantCategory: []string{"Текстовые", "Голосовые"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range cases {
|
||||
t.Run(item.template, func(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
owner := registerAndLogin(t, srv, "tpl_"+item.template, "tpl-"+item.template+"@example.com")
|
||||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds",
|
||||
`{"name":"Шаблон","template_id":"`+item.template+`"}`, owner)
|
||||
if created.Code != http.StatusOK {
|
||||
t.Fatalf("создание = %d (%s)", created.Code, created.Body.String())
|
||||
}
|
||||
guildID := decodeResponse[struct {
|
||||
Guild struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"guild"`
|
||||
}](t, created).Guild.ID
|
||||
|
||||
roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/roles", "", owner)
|
||||
roleNames := make([]string, 0, 4)
|
||||
for _, role := range decodeResponse[struct {
|
||||
Roles []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"roles"`
|
||||
}](t, roles).Roles {
|
||||
roleNames = append(roleNames, role.Name)
|
||||
}
|
||||
for _, want := range item.wantRoles {
|
||||
if !contains(roleNames, want) {
|
||||
t.Fatalf("роль %q не создана: %v", want, roleNames)
|
||||
}
|
||||
}
|
||||
|
||||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner)
|
||||
list := decodeResponse[struct {
|
||||
Channels []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ParentID string `json:"parent_id"`
|
||||
} `json:"channels"`
|
||||
}](t, channels)
|
||||
byType := map[string][]string{}
|
||||
for _, channel := range list.Channels {
|
||||
byType[channel.Type] = append(byType[channel.Type], channel.Name)
|
||||
// Комнаты шаблона лежат внутри своих категорий.
|
||||
if len(item.wantCategory) > 0 && channel.Type != "category" && channel.ParentID == "" {
|
||||
t.Fatalf("комната %q не привязана к категории", channel.Name)
|
||||
}
|
||||
}
|
||||
for _, want := range item.wantText {
|
||||
if !contains(byType["text"], want) {
|
||||
t.Fatalf("текстовая комната %q не создана: %v", want, byType["text"])
|
||||
}
|
||||
}
|
||||
for _, want := range item.wantVoice {
|
||||
if !contains(byType["voice"], want) {
|
||||
t.Fatalf("голосовая комната %q не создана: %v", want, byType["voice"])
|
||||
}
|
||||
}
|
||||
for _, want := range item.wantCategory {
|
||||
if !contains(byType["category"], want) {
|
||||
t.Fatalf("категория %q не создана: %v", want, byType["category"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuildWithUnknownTemplateRejected(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
owner := registerAndLogin(t, srv, "tpl_bad", "tpl-bad@example.com")
|
||||
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds",
|
||||
`{"name":"Странный","template_id":"нет-такого"}`, owner)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("неизвестный шаблон = %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// contains проверяет наличие строки в списке.
|
||||
func contains(list []string, value string) bool {
|
||||
for _, item := range list {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -27,6 +27,8 @@ type Channel struct {
|
||||
SlowmodeSeconds int
|
||||
NSFW bool
|
||||
Background string
|
||||
// BackgroundFileID — фон комнаты: файл-картинка (AGENT.md 7.5).
|
||||
BackgroundFileID *uint64
|
||||
VoiceStatus string
|
||||
UserLimit int
|
||||
CreatedAt time.Time
|
||||
@@ -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,6 +97,9 @@ type UpdateChannelParams struct {
|
||||
SlowmodeSeconds *int
|
||||
NSFW *bool
|
||||
Background *string
|
||||
// BackgroundFileID задаёт новый фон, ClearBackground снимает его.
|
||||
BackgroundFileID *uint64
|
||||
ClearBackground bool
|
||||
VoiceStatus *string
|
||||
UserLimit *int
|
||||
}
|
||||
@@ -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)
|
||||
@@ -212,11 +224,12 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -384,13 +384,17 @@ type GuildMemberProfile struct {
|
||||
JoinedAt time.Time
|
||||
TimeoutUntil *time.Time
|
||||
RoleIDs []uint64
|
||||
// Badges — системные бейджи пользователя (AGENT.md 7.2): список
|
||||
// расширяемый, назначается только системой.
|
||||
Badges []string
|
||||
}
|
||||
|
||||
// ListGuildMemberProfiles возвращает участников сервера с профилями и ролями.
|
||||
func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT m.user_id, u.username, u.display_name, m.nickname, u.avatar_file_id,
|
||||
u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until
|
||||
u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until,
|
||||
u.badges_json
|
||||
FROM guild_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.guild_id = ? AND u.deleted_at IS NULL
|
||||
@@ -409,11 +413,16 @@ func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]
|
||||
isAdmin int
|
||||
joinedAt string
|
||||
timeoutUntil sql.NullString
|
||||
badges string
|
||||
)
|
||||
if err := rows.Scan(&profile.UserID, &profile.Username, &profile.DisplayName, &nickname,
|
||||
&avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil); err != nil {
|
||||
&avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil,
|
||||
&badges); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil {
|
||||
profile.Badges = nil
|
||||
}
|
||||
if nickname.Valid {
|
||||
profile.Nickname = nickname.String
|
||||
}
|
||||
|
||||
+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']);
|
||||
|
||||
+25
-2
@@ -17,10 +17,33 @@ export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'c
|
||||
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
|
||||
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
|
||||
|
||||
export async function createGuild(name: string): Promise<GuildSummary> {
|
||||
/** Встроенный шаблон структуры сервера (AGENT.md 7.3). */
|
||||
export interface GuildTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
roles: string[];
|
||||
text_channels: number;
|
||||
voice_channels: number;
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export const guildTemplatesQueryKey = ['guild-templates'] as const;
|
||||
|
||||
/** `GET /guild-templates` — встроенные шаблоны серверов. */
|
||||
export async function fetchGuildTemplates(signal?: AbortSignal): Promise<GuildTemplate[]> {
|
||||
const payload = await request<{ templates: GuildTemplate[] }>(
|
||||
'/guild-templates',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.templates;
|
||||
}
|
||||
|
||||
/** `POST /guilds` — создать сервер по шаблону (пусто — «Пустой»). */
|
||||
export async function createGuild(name: string, templateId = ''): Promise<GuildSummary> {
|
||||
const payload = await request<{ guild: GuildSummary }>('/guilds', {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
body: templateId === '' ? { name } : { name, template_id: templateId },
|
||||
});
|
||||
return payload.guild;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -141,6 +143,8 @@ export interface GuildMember {
|
||||
is_instance_admin: boolean;
|
||||
joined_at: string;
|
||||
role_ids: string[];
|
||||
/** Системные бейджи участника (AGENT.md 7.2). */
|
||||
badges?: string[];
|
||||
/** Вычисленное оформление участника на сервере (AGENT.md 7.2). */
|
||||
cosmetics?: MemberCosmetics;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
|
||||
import { MessageItem } from '@/components/chat/MessageItem';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
@@ -301,6 +302,11 @@ export function MessageList({
|
||||
/>
|
||||
)}
|
||||
</Nickname>
|
||||
<SystemBadges
|
||||
badges={undefined}
|
||||
isOwner={author.isOwner}
|
||||
isInstanceAdmin={author.isInstanceAdmin}
|
||||
/>
|
||||
{headType === 'webhook' ? (
|
||||
<span
|
||||
data-testid="webhook-badge"
|
||||
@@ -309,11 +315,7 @@ export function MessageList({
|
||||
{t('chat.webhookBadge')}
|
||||
</span>
|
||||
) : null}
|
||||
{author.isInstanceAdmin ? (
|
||||
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
|
||||
{t('chat.adminBadge')}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<time
|
||||
dateTime={first.created_at}
|
||||
className="text-xs text-fg-muted"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ChatAuthor {
|
||||
avatarUrl?: string | undefined;
|
||||
colorHex: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
/** Владелец сервера: бейдж вычисляется на клиенте (AGENT.md 7.2). */
|
||||
isOwner: boolean;
|
||||
isMember: boolean;
|
||||
/** Вычисленное оформление участника на этом сервере (AGENT.md 7.2). */
|
||||
cosmetics?: MemberCosmetics | undefined;
|
||||
@@ -54,6 +56,16 @@ export interface AuthorDirectory {
|
||||
export function useAuthorDirectory(guildId: string | null, userIds: string[]): AuthorDirectory {
|
||||
const { t } = useTranslation();
|
||||
const currentUserId = useSessionStore((state) => state.user?.id ?? null);
|
||||
// Владелец сервера: бейдж владельца показывается рядом с ником (7.2).
|
||||
const ownerId = useSessionStore(
|
||||
useCallback(
|
||||
(state) =>
|
||||
guildId === null
|
||||
? null
|
||||
: (state.guilds.find((guild) => guild.id === guildId)?.owner_id ?? null),
|
||||
[guildId],
|
||||
),
|
||||
);
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: guildMembersQueryKey(guildId ?? ''),
|
||||
@@ -161,6 +173,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
};
|
||||
}
|
||||
@@ -172,6 +185,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
avatarFileId: member.avatar_file_id,
|
||||
colorHex: roleColorForMember(member, rolesData ?? []),
|
||||
isInstanceAdmin: member.is_instance_admin,
|
||||
isOwner: ownerId !== null && userId === ownerId,
|
||||
isMember: true,
|
||||
...(member.cosmetics === undefined ? {} : { cosmetics: member.cosmetics }),
|
||||
};
|
||||
@@ -184,6 +198,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
avatarFileId: userAvatarMap.get(userId),
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
...(fallbackCosmetics === undefined ? {} : { cosmetics: fallbackCosmetics }),
|
||||
};
|
||||
@@ -203,6 +218,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
avatarUrl: message.webhook_avatar,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
});
|
||||
|
||||
@@ -224,6 +240,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
};
|
||||
}, [
|
||||
memberMap,
|
||||
ownerId,
|
||||
rolesData,
|
||||
userNameMap,
|
||||
userAvatarMap,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||
import { Popover } from '@/components/ui/Popover';
|
||||
@@ -93,12 +94,15 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */}
|
||||
{/* Ник с оформлением «для друзей»: цвет, эффект, иконка и бейджи. */}
|
||||
<span className="flex items-center gap-1">
|
||||
<Nickname
|
||||
name={displayName}
|
||||
cosmetics={friend.cosmetics}
|
||||
className="text-sm font-medium"
|
||||
/>
|
||||
<SystemBadges badges={friend.badges} isInstanceAdmin={friend.is_instance_admin} />
|
||||
</span>
|
||||
<span className="block truncate text-xs text-fg-muted">@{friend.username}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { badgesFor, systemBadge } from '@/lib/badges';
|
||||
|
||||
interface SystemBadgesProps {
|
||||
/** Имена бейджей из `badges_json` (AGENT.md 7.2). */
|
||||
badges?: readonly string[] | undefined;
|
||||
isOwner?: boolean;
|
||||
isInstanceAdmin?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Системные бейджи рядом с ником: администратор инстанса, владелец сервера и
|
||||
* другие метки из `badges_json`. Пользователь их не редактирует.
|
||||
*/
|
||||
export function SystemBadges({
|
||||
badges,
|
||||
isOwner = false,
|
||||
isInstanceAdmin = false,
|
||||
className = '',
|
||||
}: SystemBadgesProps) {
|
||||
const { t } = useTranslation();
|
||||
const list = badgesFor(badges, isOwner, isInstanceAdmin);
|
||||
if (list.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-0.5 ${className}`} data-testid="system-badges">
|
||||
{list.map((id) => {
|
||||
const badge = systemBadge(id);
|
||||
const label = badge.labelKey === '' ? id : t(badge.labelKey);
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-testid={`system-badge-${id}`}
|
||||
className="cursor-help text-xs leading-none"
|
||||
>
|
||||
{badge.icon}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -194,7 +194,9 @@
|
||||
"hint": "You can rename it later in the server settings.",
|
||||
"submit": "Create",
|
||||
"limit": "You can create at most {{count}} servers on this instance.",
|
||||
"limitReached": "You have reached the server limit — remove one of your servers first."
|
||||
"limitReached": "You have reached the server limit — remove one of your servers first.",
|
||||
"template": "Server template",
|
||||
"templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles"
|
||||
},
|
||||
"empty": {
|
||||
"title": "You have no servers yet",
|
||||
@@ -1104,7 +1106,20 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"instanceAdmin": "Instance administrator",
|
||||
"guildOwner": "Server owner",
|
||||
"earlySupporter": "Early supporter",
|
||||
"verified": "Verified"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,9 @@
|
||||
"hint": "Название можно изменить позже в настройках сервера.",
|
||||
"submit": "Создать",
|
||||
"limit": "На инстансе можно создать не более {{count}} серверов.",
|
||||
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих."
|
||||
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.",
|
||||
"template": "Шаблон сервера",
|
||||
"templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли"
|
||||
},
|
||||
"empty": {
|
||||
"title": "У вас пока нет серверов",
|
||||
@@ -1104,7 +1106,20 @@
|
||||
"limit": "занято {{count}}",
|
||||
"deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.",
|
||||
"stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}."
|
||||
},
|
||||
"title": "Фон комнаты",
|
||||
"description": "Картинка за лентой сообщений. Анимированные GIF и WebP сохраняют анимацию; при системной настройке «уменьшить движение» показывается первый кадр.",
|
||||
"empty": "Фон не задан.",
|
||||
"upload": "Загрузить фон",
|
||||
"remove": "Убрать фон",
|
||||
"notImage": "Нужен файл изображения",
|
||||
"tooLarge": "Файл больше 5 МБ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"instanceAdmin": "Администратор инстанса",
|
||||
"guildOwner": "Владелец сервера",
|
||||
"earlySupporter": "Ранний сторонник",
|
||||
"verified": "Проверенный"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Системные бейджи (AGENT.md 7.2): метки рядом с ником, которые назначает
|
||||
* только система. Список расширяемый (`badges_json`), поэтому неизвестные
|
||||
* бейджи показываются нейтральной меткой, а не пропадают.
|
||||
*/
|
||||
|
||||
export interface SystemBadge {
|
||||
/** Имя бейджа из `badges_json`. */
|
||||
id: string;
|
||||
/** Значок: эмодзи, чтобы не тащить в бандл отдельные картинки. */
|
||||
icon: string;
|
||||
/** Ключ подписи в локалях. */
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
/** Известные системные бейджи. */
|
||||
export const SYSTEM_BADGES: SystemBadge[] = [
|
||||
{ id: 'instance_admin', icon: '🛡', labelKey: 'badges.instanceAdmin' },
|
||||
{ id: 'guild_owner', icon: '👑', labelKey: 'badges.guildOwner' },
|
||||
{ id: 'early_supporter', icon: '💎', labelKey: 'badges.earlySupporter' },
|
||||
{ id: 'verified', icon: '✔', labelKey: 'badges.verified' },
|
||||
];
|
||||
|
||||
/** Бейдж владельца сервера: система его не хранит, он вычисляется на клиенте. */
|
||||
export const GUILD_OWNER_BADGE = 'guild_owner';
|
||||
|
||||
/** Описание бейджа: известные — из каталога, остальные — нейтральные. */
|
||||
export function systemBadge(id: string): SystemBadge {
|
||||
const known = SYSTEM_BADGES.find((badge) => badge.id === id);
|
||||
if (known !== undefined) {
|
||||
return known;
|
||||
}
|
||||
return { id, icon: '•', labelKey: '' };
|
||||
}
|
||||
|
||||
/** Список бейджей участника: системные плюс вычисленный бейдж владельца. */
|
||||
export function badgesFor(
|
||||
badges: readonly string[] | undefined,
|
||||
isOwner: boolean,
|
||||
isInstanceAdmin = false,
|
||||
): string[] {
|
||||
const result = new Set<string>(badges ?? []);
|
||||
// Администратор инстанса всегда помечен, даже если `badges_json` пуст
|
||||
// (бейдж назначается системой при выдаче прав).
|
||||
if (isInstanceAdmin) {
|
||||
result.add('instance_admin');
|
||||
}
|
||||
if (isOwner) {
|
||||
result.add(GUILD_OWNER_BADGE);
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { createGuild } from '@/api/guilds';
|
||||
import { createGuild, fetchGuildTemplates, guildTemplatesQueryKey } from '@/api/guilds';
|
||||
import { instanceQueryKey } from '@/api/instance';
|
||||
import { myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
@@ -31,9 +31,19 @@ export function CreateGuildModal() {
|
||||
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [templateId, setTemplateId] = useState('empty');
|
||||
|
||||
// Шаблоны структуры: список небольшой и статический, кэшируется на сессию.
|
||||
const templates = useQuery({
|
||||
queryKey: guildTemplatesQueryKey,
|
||||
queryFn: ({ signal }) => fetchGuildTemplates(signal),
|
||||
enabled: open,
|
||||
staleTime: 300_000,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (guildName: string) => createGuild(guildName),
|
||||
mutationFn: (guildName: string) => createGuild(guildName, templateId),
|
||||
onSuccess: (guild) => {
|
||||
close();
|
||||
setName('');
|
||||
@@ -83,6 +93,43 @@ export function CreateGuildModal() {
|
||||
maxLength={64}
|
||||
hint={t('guilds.create.hint')}
|
||||
/>
|
||||
{/* Шаблон задаёт структуру комнат и ролей (AGENT.md 7.3). */}
|
||||
<fieldset className="flex flex-col gap-2" data-testid="guild-templates">
|
||||
<legend className="text-sm font-medium">{t('guilds.create.template')}</legend>
|
||||
{(templates.data ?? []).map((template) => (
|
||||
<label
|
||||
key={template.id}
|
||||
data-testid={`guild-template-${template.id}`}
|
||||
className={`flex cursor-pointer flex-col gap-0.5 rounded-[var(--radius-md)] border px-3 py-2 transition-colors duration-[var(--duration-fast)] ${
|
||||
templateId === template.id
|
||||
? 'border-accent bg-accent/10'
|
||||
: 'border-border/60 hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="guild_template"
|
||||
value={template.id}
|
||||
checked={templateId === template.id}
|
||||
onChange={() => setTemplateId(template.id)}
|
||||
/>
|
||||
<span className="text-sm font-medium">{template.name}</span>
|
||||
<span className="ml-auto text-xs text-fg-muted">
|
||||
{t('guilds.create.templateCounts', {
|
||||
text: template.text_channels,
|
||||
voice: template.voice_channels,
|
||||
roles: template.roles.length,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-fg-muted">{template.description}</span>
|
||||
</label>
|
||||
))}
|
||||
{templates.isError ? (
|
||||
<ErrorNotice error={templates.error} onRetry={() => void templates.refetch()} />
|
||||
) : null}
|
||||
</fieldset>
|
||||
{instance.data === undefined ? null : (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { GuildMember } from '@/api/types';
|
||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Badge } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
@@ -20,6 +21,8 @@ interface GuildMembersSectionProps {
|
||||
canTimeout?: boolean;
|
||||
/** Свой профиль: себя модерировать нельзя. */
|
||||
currentUserId?: string | undefined;
|
||||
/** Владелец сервера: рядом с ником показывается его бейдж (AGENT.md 7.2). */
|
||||
ownerId?: string | undefined;
|
||||
}
|
||||
|
||||
/** Сколько участников показываем в превью (полный список — в следующих фазах). */
|
||||
@@ -36,6 +39,7 @@ export function GuildMembersSection({
|
||||
canBan = false,
|
||||
canTimeout = false,
|
||||
currentUserId,
|
||||
ownerId,
|
||||
}: GuildMembersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const preview = (members ?? []).slice(0, PREVIEW_LIMIT);
|
||||
@@ -72,11 +76,18 @@ export function GuildMembersSection({
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
{/* Оформление участника приходит уже вычисленным (AGENT.md 7.2). */}
|
||||
<span className="flex items-center gap-1">
|
||||
<Nickname
|
||||
name={name}
|
||||
cosmetics={member.cosmetics}
|
||||
className="max-w-full text-sm font-medium"
|
||||
/>
|
||||
<SystemBadges
|
||||
badges={member.badges}
|
||||
isInstanceAdmin={member.is_instance_admin}
|
||||
isOwner={member.user_id === ownerId}
|
||||
/>
|
||||
</span>
|
||||
<span className="block truncate text-xs text-fg-muted">
|
||||
{member.nickname === undefined || member.nickname === ''
|
||||
? `@${member.username}`
|
||||
|
||||
@@ -212,6 +212,7 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
canBan={canBan}
|
||||
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
|
||||
currentUserId={currentUser.data?.id}
|
||||
ownerId={guild.owner_id}
|
||||
/>
|
||||
<GuildCosmeticsSection
|
||||
key={`${guild.id}-cosmetics`}
|
||||
|
||||
@@ -101,6 +101,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
@@ -109,6 +110,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({
|
||||
|
||||
@@ -31,6 +31,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
@@ -39,6 +40,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
|
||||
|
||||
@@ -72,6 +72,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
@@ -80,6 +81,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId, isMe: false }),
|
||||
|
||||
@@ -257,6 +257,8 @@ interface SnapshotGuildInput {
|
||||
parent_id?: string;
|
||||
can_send?: 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.can_send === undefined ? {} : { can_send: channel.can_send }),
|
||||
...(channel.can_view === undefined ? {} : { can_view: channel.can_view }),
|
||||
...(channel.background_file_id === undefined
|
||||
? {}
|
||||
: { background_file_id: channel.background_file_id }),
|
||||
})),
|
||||
roles: [],
|
||||
member_ids: ['user-1'],
|
||||
|
||||
@@ -19,6 +19,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
@@ -27,6 +28,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
|
||||
@@ -223,6 +225,7 @@ describe('сообщения вебхуков', () => {
|
||||
avatarUrl: message.webhook_avatar,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -2,14 +2,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
|
||||
import {
|
||||
bodyOf,
|
||||
findRequest,
|
||||
installFetch,
|
||||
installGatewaySocket,
|
||||
json,
|
||||
makeReadySnapshot,
|
||||
makeUser,
|
||||
messagesRoutes,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
resetStores,
|
||||
@@ -322,3 +327,203 @@ describe('оформление «для друзей»', () => {
|
||||
expect(within(row).getByTestId('nick-name')).toHaveStyle({ color: '#2ecc71' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('шаблоны серверов', () => {
|
||||
it('создаёт сервер по выбранному шаблону', async () => {
|
||||
const templates = [
|
||||
{
|
||||
id: 'empty',
|
||||
name: 'Пустой',
|
||||
description: 'Одна общая текстовая комната.',
|
||||
roles: ['Администратор', 'Пользователь'],
|
||||
text_channels: 1,
|
||||
voice_channels: 0,
|
||||
categories: [],
|
||||
},
|
||||
{
|
||||
id: 'gaming',
|
||||
name: 'Игровой',
|
||||
description: 'Текстовые комнаты и две голосовые.',
|
||||
roles: ['Администратор', 'Пользователь', 'Игрок'],
|
||||
text_channels: 2,
|
||||
voice_channels: 2,
|
||||
categories: ['Текстовые', 'Голосовые'],
|
||||
},
|
||||
];
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/guild-templates', response: () => json({ templates }) },
|
||||
{
|
||||
match: '/api/v1/guilds',
|
||||
method: 'POST',
|
||||
response: () =>
|
||||
json({
|
||||
guild: {
|
||||
id: 'g-new',
|
||||
name: 'Новый',
|
||||
owner_id: 'user-1',
|
||||
is_main: false,
|
||||
member_count: 1,
|
||||
my_role_ids: [],
|
||||
my_permissions: ['ADMINISTRATOR'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ 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' } }) },
|
||||
]);
|
||||
renderApp('/app');
|
||||
await userEvent.click(await screen.findByLabelText('Создать сервер'));
|
||||
|
||||
const picker = await screen.findByTestId('guild-templates');
|
||||
await userEvent.click(within(picker).getByTestId('guild-template-gaming'));
|
||||
await userEvent.type(screen.getByLabelText('Название сервера'), 'Новый');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Создать' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bodyOf(findRequest(fetchMock, { url: '/guilds', method: 'POST' }))).toMatchObject({
|
||||
name: 'Новый',
|
||||
template_id: 'gaming',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('системные бейджи', () => {
|
||||
it('показывает щит администратора инстанса и корону владельца', () => {
|
||||
render(<SystemBadges badges={['instance_admin', 'early_supporter']} isOwner isInstanceAdmin />);
|
||||
|
||||
expect(screen.getByTestId('system-badge-instance_admin')).toHaveTextContent('🛡');
|
||||
expect(screen.getByTestId('system-badge-guild_owner')).toHaveTextContent('👑');
|
||||
expect(screen.getByTestId('system-badge-early_supporter')).toHaveAttribute(
|
||||
'title',
|
||||
'Ранний сторонник',
|
||||
);
|
||||
// Администратор инстанса помечен даже без записи в badges_json.
|
||||
expect(screen.getByLabelText('Администратор инстанса')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('неизвестный бейдж показывается нейтральной меткой, а не пропадает', () => {
|
||||
render(<SystemBadges badges={['future_badge']} />);
|
||||
|
||||
const badge = screen.getByTestId('system-badge-future_badge');
|
||||
expect(badge).toHaveTextContent('•');
|
||||
expect(badge).toHaveAttribute('title', 'future_badge');
|
||||
});
|
||||
|
||||
it('без бейджей ничего не рисует', () => {
|
||||
const { container } = render(<SystemBadges />);
|
||||
expect(container.querySelector('[data-testid="system-badges"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,6 +337,7 @@ describe('основной экран', () => {
|
||||
const createCall = recordedRequests(fetchMock).find(
|
||||
(request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST',
|
||||
);
|
||||
expect(createCall?.body).toEqual({ name: 'Новый' });
|
||||
// Шаблон по умолчанию — «Пустой»: структура комнат и ролей (AGENT.md 7.3).
|
||||
expect(createCall?.body).toEqual({ name: 'Новый', template_id: 'empty' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: userId === 'user-2',
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
@@ -37,6 +38,7 @@ const authors: AuthorDirectory = {
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
|
||||
|
||||
Reference in New Issue
Block a user