feat(cosmetics): оформление профиля — галерея, роли и личные стили
- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей» и пер-серверные стили) (AGENT.md 7.2, 7.4) - store: CRUD галереи, личные стили и вычисление оформления поэлементно (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль больше не выдаёт элемент - API: галерея сервера под MANAGE_ROLES, оформление роли в create/update, GET/PUT/DELETE /users/@me/styles с доступными элементами по областям - клиент: редактор личного стиля с предпросмотром, галерея оформления в настройках сервера, оформление роли в разделе «Роли» - рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты ника (CSS, при reduced-motion статично) в ленте и списке участников
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Галерея оформления сервера (AGENT.md 7.4): рамки аватара и иконки рядом с
|
||||||
|
-- ником. Файлы лежат обычными загрузками, поэтому анимация сохраняется.
|
||||||
|
CREATE TABLE guild_cosmetics (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
file_id INTEGER NOT NULL REFERENCES files (id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX guild_cosmetics_guild_idx ON guild_cosmetics (guild_id, kind, id);
|
||||||
|
|
||||||
|
-- Личные стили профиля: guild_id IS NULL — стиль «для друзей» (DM, друзья,
|
||||||
|
-- глобальные места), иначе переопределение для конкретного сервера (7.2).
|
||||||
|
CREATE TABLE user_styles (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
guild_id INTEGER REFERENCES guilds (id) ON DELETE CASCADE,
|
||||||
|
frame_id INTEGER REFERENCES guild_cosmetics (id) ON DELETE SET NULL,
|
||||||
|
badge_id INTEGER REFERENCES guild_cosmetics (id) ON DELETE SET NULL,
|
||||||
|
nick_color INTEGER,
|
||||||
|
nick_effect TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX user_styles_scope_idx ON user_styles (user_id, IFNULL(guild_id, 0));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP INDEX user_styles_scope_idx;
|
||||||
|
DROP TABLE user_styles;
|
||||||
|
DROP INDEX guild_cosmetics_guild_idx;
|
||||||
|
DROP TABLE guild_cosmetics;
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"glchat/internal/permissions"
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Оформление профиля (AGENT.md 7.2, 7.4): галерея сервера с рамками и
|
||||||
|
// иконками, назначение их ролям и личные стили пользователя («для друзей» и
|
||||||
|
// пер-серверные) с поэлементным приоритетом.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxCosmeticsPerGuild — предел галереи сервера.
|
||||||
|
maxCosmeticsPerGuild = 50
|
||||||
|
// maxCosmeticSize — предел размера картинки оформления.
|
||||||
|
maxCosmeticSize = 5 << 20
|
||||||
|
// maxCosmeticNameLength — длина имени элемента галереи.
|
||||||
|
maxCosmeticNameLength = 40
|
||||||
|
// scopeGlobal — область личного стиля «для друзей».
|
||||||
|
scopeGlobal = "global"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cosmeticPayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GuildID string `json:"guild_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cosmeticListOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Cosmetics []cosmeticPayload `json:"cosmetics"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cosmeticOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Cosmetic cosmeticPayload `json:"cosmetic"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cosmeticsPayload — вычисленное оформление участника (AGENT.md 7.2).
|
||||||
|
type cosmeticsPayload struct {
|
||||||
|
FrameID string `json:"frame_id,omitempty"`
|
||||||
|
BadgeID string `json:"badge_id,omitempty"`
|
||||||
|
NickColor *int64 `json:"nick_color,omitempty"`
|
||||||
|
NickEffect string `json:"nick_effect,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// stylePayload — личный стиль профиля вместе с элементами, которые доступны
|
||||||
|
// пользователю: клиент показывает выбор только из них (7.2).
|
||||||
|
type stylePayload struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
GuildID string `json:"guild_id,omitempty"`
|
||||||
|
GuildName string `json:"guild_name,omitempty"`
|
||||||
|
// Style — записанный стиль области (пустой, если его нет).
|
||||||
|
Style cosmeticsPayload `json:"style"`
|
||||||
|
FrameIDs []string `json:"frame_ids"`
|
||||||
|
BadgeIDs []string `json:"badge_ids"`
|
||||||
|
NickColors []int64 `json:"nick_colors"`
|
||||||
|
Effects []string `json:"effects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type styleListOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Styles []stylePayload `json:"styles"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type styleOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Style stylePayload `json:"style"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerCosmeticRoutes описывает галерею оформления сервера и личные стили
|
||||||
|
// (AGENT.md 7.2, 7.4).
|
||||||
|
func (s *Server) registerCosmeticRoutes(api huma.API, router chi.Router) {
|
||||||
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "listGuildCosmetics",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/guilds/{guild_id}/cosmetics",
|
||||||
|
Summary: "Галерея оформления сервера",
|
||||||
|
Tags: []string{"Cosmetics"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
GuildID string `path:"guild_id"`
|
||||||
|
},
|
||||||
|
) (*cosmeticListOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cosmetics, err := s.store.ListGuildCosmetics(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &cosmeticListOutput{}
|
||||||
|
output.Body.Cosmetics = make([]cosmeticPayload, 0, len(cosmetics))
|
||||||
|
for i := range cosmetics {
|
||||||
|
output.Body.Cosmetics = append(output.Body.Cosmetics, cosmeticFromStore(cosmetics[i]))
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "renameGuildCosmetic",
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/guilds/{guild_id}/cosmetics/{cosmetic_id}",
|
||||||
|
Summary: "Переименовать элемент галереи",
|
||||||
|
Tags: []string{"Cosmetics"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
GuildID string `path:"guild_id"`
|
||||||
|
CosmeticID string `path:"cosmetic_id"`
|
||||||
|
Body struct {
|
||||||
|
Name string `json:"name" minLength:"1" maxLength:"40"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*cosmeticOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.CosmeticID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(input.Body.Name)
|
||||||
|
if name == "" || len([]rune(name)) > maxCosmeticNameLength {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid cosmetic name")
|
||||||
|
}
|
||||||
|
updated, err := s.store.RenameGuildCosmetic(ctx, cosmetic.ID, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, guildID, "cosmetic.update", "cosmetic", &cosmetic.ID, "")
|
||||||
|
s.dispatchGuildCosmeticsUpdate(guildID)
|
||||||
|
output := &cosmeticOutput{}
|
||||||
|
output.Body.Cosmetic = cosmeticFromStore(*updated)
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "deleteGuildCosmetic",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/guilds/{guild_id}/cosmetics/{cosmetic_id}",
|
||||||
|
Summary: "Удалить элемент галереи",
|
||||||
|
Tags: []string{"Cosmetics"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
GuildID string `path:"guild_id"`
|
||||||
|
CosmeticID string `path:"cosmetic_id"`
|
||||||
|
},
|
||||||
|
) (*okOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.CosmeticID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteGuildCosmetic(ctx, cosmetic.ID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
// Файл больше не нужен: галерея не должна копить мусор (AGENT.md 7.7).
|
||||||
|
s.deleteStoredFile(ctx, cosmetic.FileID)
|
||||||
|
s.recordAudit(ctx, user, guildID, "cosmetic.delete", "cosmetic", &cosmetic.ID, "")
|
||||||
|
s.dispatchGuildCosmeticsUpdate(guildID)
|
||||||
|
return newOKOutput(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Загрузка файла галереи — multipart, поэтому ручка живёт на роутере.
|
||||||
|
router.Post("/guilds/{guild_id}/cosmetics", s.handleCosmeticUpload)
|
||||||
|
s.registerStyleRoutes(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCosmeticUpload принимает картинку рамки или иконки (AGENT.md 7.4).
|
||||||
|
func (s *Server) handleCosmeticUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _, ok := s.authenticate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
guildID, err := parseID("guild_id", chi.URLParam(r, "guild_id"))
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolved, err := s.guildPermissions(ctx, guildID, user)
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !resolved.Has(permissions.ManageRoles) {
|
||||||
|
httpxWriteJSONError(w, http.StatusForbidden, "perm.denied", "MANAGE_ROLES is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count, err := s.store.CountGuildCosmetics(ctx, guildID); err != nil {
|
||||||
|
writeHumaAPIError(w, humaError(err))
|
||||||
|
return
|
||||||
|
} else if count >= maxCosmeticsPerGuild {
|
||||||
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "limits.reached", "cosmetic gallery is full")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен ниже
|
||||||
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
_ = r.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
kind := store.CosmeticKind(strings.TrimSpace(r.FormValue("kind")))
|
||||||
|
if kind != store.CosmeticFrame && kind != store.CosmeticBadge {
|
||||||
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "kind must be frame or badge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
name = string(kind)
|
||||||
|
}
|
||||||
|
if len([]rune(name)) > maxCosmeticNameLength {
|
||||||
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "invalid cosmetic name")
|
||||||
|
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 > maxCosmeticSize {
|
||||||
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, maxCosmeticSize+1))
|
||||||
|
if err != nil || int64(len(data)) > maxCosmeticSize {
|
||||||
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic 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", "cosmetic must be an image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := s.saveUpload(ctx, store.File{
|
||||||
|
UploaderID: &user.ID,
|
||||||
|
GuildID: &guildID,
|
||||||
|
Filename: sanitizeFilename(header.Filename),
|
||||||
|
ContentType: contentType,
|
||||||
|
Purpose: "guild_cosmetic",
|
||||||
|
}, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cosmetic, err := s.store.CreateGuildCosmetic(ctx, store.CreateGuildCosmeticParams{
|
||||||
|
GuildID: guildID,
|
||||||
|
Kind: kind,
|
||||||
|
Name: name,
|
||||||
|
FileID: stored.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.deleteStoredFile(ctx, stored.ID)
|
||||||
|
writeHumaAPIError(w, humaError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, guildID, "cosmetic.create", "cosmetic", &cosmetic.ID, "")
|
||||||
|
s.dispatchGuildCosmeticsUpdate(guildID)
|
||||||
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"cosmetic": cosmeticFromStore(*cosmetic)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerStyleRoutes описывает личные стили профиля (AGENT.md 7.2).
|
||||||
|
func (s *Server) registerStyleRoutes(api huma.API) {
|
||||||
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "listMyStyles",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/users/@me/styles",
|
||||||
|
Summary: "Личные стили профиля и доступные элементы",
|
||||||
|
Tags: []string{"Cosmetics"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, _ *struct{}) (*styleListOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
styles, err := s.store.ListUserStyles(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
cosmetics, err := s.store.ListGrantedCosmetics(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
colors := store.NickColorPalette()
|
||||||
|
|
||||||
|
// Доступность элементов считается для каждой области отдельно (7.2):
|
||||||
|
// пер-серверный стиль ограничен ролями этого сервера, «для друзей» —
|
||||||
|
// ролями на любом сервере.
|
||||||
|
byScope := make(map[string]*store.UserStyle, len(styles))
|
||||||
|
for i := range styles {
|
||||||
|
byScope[scopeOfStyle(styles[i])] = &styles[i]
|
||||||
|
}
|
||||||
|
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &styleListOutput{}
|
||||||
|
output.Body.Styles = append(output.Body.Styles,
|
||||||
|
styleFromStore(byScope[scopeGlobal],
|
||||||
|
grantedIDs(cosmetics, grants[user.ID].Frames, store.CosmeticFrame),
|
||||||
|
grantedIDs(cosmetics, grants[user.ID].Badges, store.CosmeticBadge),
|
||||||
|
colors))
|
||||||
|
|
||||||
|
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
for i := range guilds {
|
||||||
|
guildID := guilds[i].ID
|
||||||
|
scope := formatSnowflake(guildID)
|
||||||
|
scoped, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, &guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
entry := scoped[user.ID]
|
||||||
|
payload := styleFromStore(byScope[scope],
|
||||||
|
grantedIDs(cosmetics, entry.GuildFrames, store.CosmeticFrame),
|
||||||
|
grantedIDs(cosmetics, entry.GuildBadges, store.CosmeticBadge),
|
||||||
|
colors)
|
||||||
|
payload.GuildName = guilds[i].Name
|
||||||
|
output.Body.Styles = append(output.Body.Styles, payload)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "putMyStyle",
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/users/@me/styles/{scope}",
|
||||||
|
Summary: "Задать личный стиль профиля",
|
||||||
|
Tags: []string{"Cosmetics"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
Scope string `path:"scope"`
|
||||||
|
Body struct {
|
||||||
|
FrameID string `json:"frame_id,omitempty"`
|
||||||
|
BadgeID string `json:"badge_id,omitempty"`
|
||||||
|
NickColor *int64 `json:"nick_color,omitempty"`
|
||||||
|
NickEffect string `json:"nick_effect,omitempty"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*styleOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, err := parseStyleScope(input.Scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if guildID != nil {
|
||||||
|
// Пер-серверный стиль доступен только участнику сервера.
|
||||||
|
if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(*guildID), user, permissions.ViewGuild); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frameID, err := s.requiredCosmetic(ctx, user.ID, guildID, input.Body.FrameID, store.CosmeticFrame)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
badgeID, err := s.requiredCosmetic(ctx, user.ID, guildID, input.Body.BadgeID, store.CosmeticBadge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
effect := input.Body.NickEffect
|
||||||
|
if effect == "" {
|
||||||
|
effect = string(store.NickEffectNone)
|
||||||
|
}
|
||||||
|
if !store.ValidNickEffect(effect) {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown nick effect")
|
||||||
|
}
|
||||||
|
nickColor := input.Body.NickColor
|
||||||
|
if nickColor != nil && !store.InNickColorPalette(*nickColor) {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "nick color is not in the palette")
|
||||||
|
}
|
||||||
|
|
||||||
|
style, err := s.store.UpsertUserStyle(ctx, store.UpsertUserStyleParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
GuildID: guildID,
|
||||||
|
FrameID: frameID,
|
||||||
|
BadgeID: badgeID,
|
||||||
|
NickColor: nickColor,
|
||||||
|
NickEffect: effect,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
cosmetics, err := s.store.ListGrantedCosmetics(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
frames := grantedIDs(cosmetics, grants[user.ID].Frames, store.CosmeticFrame)
|
||||||
|
badges := grantedIDs(cosmetics, grants[user.ID].Badges, store.CosmeticBadge)
|
||||||
|
output := &styleOutput{}
|
||||||
|
output.Body.Style = styleFromStore(style, frames, badges, store.NickColorPalette())
|
||||||
|
s.dispatchStyleUpdate(user.ID, guildID)
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "deleteMyStyle",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/users/@me/styles/{scope}",
|
||||||
|
Summary: "Сбросить личный стиль профиля",
|
||||||
|
Tags: []string{"Cosmetics"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
Scope string `path:"scope"`
|
||||||
|
},
|
||||||
|
) (*okOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
guildID, err := parseStyleScope(input.Scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteUserStyle(ctx, user.ID, guildID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.dispatchStyleUpdate(user.ID, guildID)
|
||||||
|
return newOKOutput(), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeOfStyle — область записанного стиля: `global` или id сервера.
|
||||||
|
func scopeOfStyle(style store.UserStyle) string {
|
||||||
|
if style.GuildID == nil {
|
||||||
|
return scopeGlobal
|
||||||
|
}
|
||||||
|
return formatSnowflake(*style.GuildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStyleScope разбирает область стиля: `global` или идентификатор сервера.
|
||||||
|
func parseStyleScope(scope string) (*uint64, error) {
|
||||||
|
if scope == scopeGlobal {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
guildID, err := parseID("scope", scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &guildID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requiredCosmetic проверяет, что элемент галереи существует и выдан ролями
|
||||||
|
// пользователя (AGENT.md 7.2: выбирать можно только выданное).
|
||||||
|
func (s *Server) requiredCosmetic(ctx context.Context, userID uint64, guildID *uint64, rawID string, kind store.CosmeticKind) (*uint64, error) {
|
||||||
|
if rawID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
id, err := parseID("cosmetic_id", rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cosmetic, err := s.store.GetGuildCosmetic(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if cosmetic.Kind != kind {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic kind mismatch")
|
||||||
|
}
|
||||||
|
grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{userID}, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
entry := grants[userID]
|
||||||
|
granted := entry.Frames
|
||||||
|
if kind == store.CosmeticBadge {
|
||||||
|
granted = entry.Badges
|
||||||
|
}
|
||||||
|
// Пер-серверный стиль ограничен ролями этого сервера.
|
||||||
|
if guildID != nil {
|
||||||
|
granted = entry.GuildFrames
|
||||||
|
if kind == store.CosmeticBadge {
|
||||||
|
granted = entry.GuildBadges
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !granted[id] {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "cosmetic.not_granted", "cosmetic is not granted by your roles")
|
||||||
|
}
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireGuildCosmetic достаёт элемент галереи и проверяет его сервер.
|
||||||
|
func (s *Server) requireGuildCosmetic(ctx context.Context, guildID uint64, rawID string) (*store.GuildCosmetic, error) {
|
||||||
|
id, err := parseID("cosmetic_id", rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cosmetic, err := s.store.GetGuildCosmetic(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if cosmetic.GuildID != guildID {
|
||||||
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "cosmetic not found")
|
||||||
|
}
|
||||||
|
return cosmetic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantedIDs оставляет только выданные роли элементы нужного вида.
|
||||||
|
func grantedIDs(cosmetics []store.GuildCosmetic, granted map[uint64]bool, kind store.CosmeticKind) []string {
|
||||||
|
ids := make([]string, 0, len(granted))
|
||||||
|
for _, cosmetic := range cosmetics {
|
||||||
|
if cosmetic.Kind == kind && granted[cosmetic.ID] {
|
||||||
|
ids = append(ids, formatSnowflake(cosmetic.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchGuildCosmeticsUpdate сообщает участникам, что галерея изменилась.
|
||||||
|
func (s *Server) dispatchGuildCosmeticsUpdate(guildID uint64) {
|
||||||
|
if s.gateway == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.dispatchGuildEvent(guildID, "GUILD_COSMETICS_UPDATE", map[string]any{
|
||||||
|
"guild_id": formatSnowflake(guildID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchStyleUpdate сообщает всем сессиям пользователя о смене стиля.
|
||||||
|
func (s *Server) dispatchStyleUpdate(userID uint64, guildID *uint64) {
|
||||||
|
if s.gateway == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := map[string]any{"user_id": formatSnowflake(userID)}
|
||||||
|
if guildID != nil {
|
||||||
|
payload["guild_id"] = formatSnowflake(*guildID)
|
||||||
|
}
|
||||||
|
s.gateway.SendToUser(userID, "USER_STYLE_UPDATE", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cosmeticFromStore собирает ответ по элементу галереи.
|
||||||
|
func cosmeticFromStore(cosmetic store.GuildCosmetic) cosmeticPayload {
|
||||||
|
return cosmeticPayload{
|
||||||
|
ID: formatSnowflake(cosmetic.ID),
|
||||||
|
GuildID: formatSnowflake(cosmetic.GuildID),
|
||||||
|
Kind: string(cosmetic.Kind),
|
||||||
|
Name: cosmetic.Name,
|
||||||
|
FileID: formatSnowflake(cosmetic.FileID),
|
||||||
|
CreatedAt: cosmetic.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cosmeticsFromEffective собирает вычисленное оформление участника.
|
||||||
|
func cosmeticsFromEffective(effective store.EffectiveCosmetics) *cosmeticsPayload {
|
||||||
|
if effective.IsEmpty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := &cosmeticsPayload{NickEffect: effective.NickEffect}
|
||||||
|
if effective.FrameID != nil {
|
||||||
|
payload.FrameID = formatSnowflake(*effective.FrameID)
|
||||||
|
}
|
||||||
|
if effective.BadgeID != nil {
|
||||||
|
payload.BadgeID = formatSnowflake(*effective.BadgeID)
|
||||||
|
}
|
||||||
|
if effective.NickColor != nil {
|
||||||
|
payload.NickColor = effective.NickColor
|
||||||
|
}
|
||||||
|
if payload.NickEffect == string(store.NickEffectNone) {
|
||||||
|
payload.NickEffect = ""
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// styleFromStore собирает ответ по личному стилю.
|
||||||
|
func styleFromStore(style *store.UserStyle, frames, badges []string, colors []int64) stylePayload {
|
||||||
|
payload := stylePayload{
|
||||||
|
Scope: scopeGlobal,
|
||||||
|
FrameIDs: frames,
|
||||||
|
BadgeIDs: badges,
|
||||||
|
NickColors: colors,
|
||||||
|
Effects: effectNames(),
|
||||||
|
}
|
||||||
|
if payload.FrameIDs == nil {
|
||||||
|
payload.FrameIDs = []string{}
|
||||||
|
}
|
||||||
|
if payload.BadgeIDs == nil {
|
||||||
|
payload.BadgeIDs = []string{}
|
||||||
|
}
|
||||||
|
if payload.NickColors == nil {
|
||||||
|
payload.NickColors = []int64{}
|
||||||
|
}
|
||||||
|
if style == nil {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if style.GuildID != nil {
|
||||||
|
payload.Scope = formatSnowflake(*style.GuildID)
|
||||||
|
payload.GuildID = formatSnowflake(*style.GuildID)
|
||||||
|
}
|
||||||
|
if style.FrameID != nil {
|
||||||
|
payload.Style.FrameID = formatSnowflake(*style.FrameID)
|
||||||
|
}
|
||||||
|
if style.BadgeID != nil {
|
||||||
|
payload.Style.BadgeID = formatSnowflake(*style.BadgeID)
|
||||||
|
}
|
||||||
|
if style.NickColor != nil {
|
||||||
|
payload.Style.NickColor = style.NickColor
|
||||||
|
}
|
||||||
|
if style.NickEffect != "" && style.NickEffect != string(store.NickEffectNone) {
|
||||||
|
payload.Style.NickEffect = style.NickEffect
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectNames — встроенный набор эффектов ника.
|
||||||
|
func effectNames() []string {
|
||||||
|
effects := store.NickEffects()
|
||||||
|
names := make([]string, 0, len(effects))
|
||||||
|
for _, effect := range effects {
|
||||||
|
names = append(names, string(effect))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleCosmeticsInput — оформление роли из запроса: пустая строка снимает
|
||||||
|
// элемент, `nil` оставляет как было (AGENT.md 7.4).
|
||||||
|
type roleCosmeticsInput struct {
|
||||||
|
FrameID string
|
||||||
|
BadgeID string
|
||||||
|
NickColor *int64
|
||||||
|
NickEffect string
|
||||||
|
FrameSet bool
|
||||||
|
BadgeSet bool
|
||||||
|
ColorSet bool
|
||||||
|
EffectSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRoleCosmetics проверяет и сохраняет оформление роли. Рамка и иконка
|
||||||
|
// обязаны принадлежать галерее этого сервера.
|
||||||
|
func (s *Server) applyRoleCosmetics(ctx context.Context, guildID uint64, role *store.Role, input roleCosmeticsInput) (*store.Role, error) {
|
||||||
|
params := store.UpdateRoleParams{}
|
||||||
|
if input.FrameSet {
|
||||||
|
if input.FrameID == "" {
|
||||||
|
params.ClearFrame = true
|
||||||
|
} else {
|
||||||
|
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.FrameID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cosmetic.Kind != store.CosmeticFrame {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic is not a frame")
|
||||||
|
}
|
||||||
|
params.CosmeticFrameID = &cosmetic.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.BadgeSet {
|
||||||
|
if input.BadgeID == "" {
|
||||||
|
params.ClearBadge = true
|
||||||
|
} else {
|
||||||
|
cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.BadgeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cosmetic.Kind != store.CosmeticBadge {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic is not a badge")
|
||||||
|
}
|
||||||
|
params.CosmeticBadgeID = &cosmetic.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.ColorSet {
|
||||||
|
if input.NickColor == nil {
|
||||||
|
params.ClearNickColor = true
|
||||||
|
} else {
|
||||||
|
if !store.InNickColorPalette(*input.NickColor) {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "nick color is not in the palette")
|
||||||
|
}
|
||||||
|
params.NickColor = input.NickColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.EffectSet {
|
||||||
|
effect := input.NickEffect
|
||||||
|
if effect == "" {
|
||||||
|
effect = string(store.NickEffectNone)
|
||||||
|
}
|
||||||
|
if !store.ValidNickEffect(effect) {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown nick effect")
|
||||||
|
}
|
||||||
|
params.NickEffect = &effect
|
||||||
|
}
|
||||||
|
if params.CosmeticFrameID == nil && params.CosmeticBadgeID == nil && params.NickColor == nil &&
|
||||||
|
params.NickEffect == nil && !params.ClearFrame && !params.ClearBadge && !params.ClearNickColor {
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
updated, err := s.store.UpdateRole(ctx, role.ID, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalString разыменовывает необязательную строку из тела запроса.
|
||||||
|
func optionalString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
@@ -41,6 +41,11 @@ type rolePayload struct {
|
|||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
Hoist bool `json:"hoist"`
|
Hoist bool `json:"hoist"`
|
||||||
Mentionable bool `json:"mentionable"`
|
Mentionable bool `json:"mentionable"`
|
||||||
|
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
|
||||||
|
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
|
||||||
|
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
|
||||||
|
NickColor *int64 `json:"nick_color,omitempty"`
|
||||||
|
NickEffect string `json:"nick_effect,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type memberPayload struct {
|
type memberPayload struct {
|
||||||
@@ -55,6 +60,8 @@ type memberPayload struct {
|
|||||||
JoinedAt string `json:"joined_at"`
|
JoinedAt string `json:"joined_at"`
|
||||||
TimeoutUntil string `json:"timeout_until,omitempty"`
|
TimeoutUntil string `json:"timeout_until,omitempty"`
|
||||||
RoleIDs []string `json:"role_ids"`
|
RoleIDs []string `json:"role_ids"`
|
||||||
|
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
|
||||||
|
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type guildDetailPayload struct {
|
type guildDetailPayload struct {
|
||||||
@@ -99,7 +106,7 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved) cha
|
|||||||
}
|
}
|
||||||
|
|
||||||
func roleFromStore(role store.Role) rolePayload {
|
func roleFromStore(role store.Role) rolePayload {
|
||||||
return rolePayload{
|
payload := rolePayload{
|
||||||
ID: formatSnowflake(role.ID),
|
ID: formatSnowflake(role.ID),
|
||||||
Name: role.Name,
|
Name: role.Name,
|
||||||
Color: role.Color,
|
Color: role.Color,
|
||||||
@@ -108,7 +115,18 @@ func roleFromStore(role store.Role) rolePayload {
|
|||||||
IsDefault: role.IsDefault,
|
IsDefault: role.IsDefault,
|
||||||
Hoist: role.Hoist,
|
Hoist: role.Hoist,
|
||||||
Mentionable: role.Mentionable,
|
Mentionable: role.Mentionable,
|
||||||
|
NickColor: role.NickColor,
|
||||||
}
|
}
|
||||||
|
if role.CosmeticFrameID != nil {
|
||||||
|
payload.CosmeticFrameID = formatSnowflake(*role.CosmeticFrameID)
|
||||||
|
}
|
||||||
|
if role.CosmeticBadgeID != nil {
|
||||||
|
payload.CosmeticBadgeID = formatSnowflake(*role.CosmeticBadgeID)
|
||||||
|
}
|
||||||
|
if role.NickEffect != "" && role.NickEffect != string(store.NickEffectNone) {
|
||||||
|
payload.NickEffect = role.NickEffect
|
||||||
|
}
|
||||||
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
type guildOutput struct {
|
type guildOutput struct {
|
||||||
@@ -743,13 +761,20 @@ func (s *Server) registerMemberRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
// Оформление считается поэлементно для всего сервера (AGENT.md 7.2).
|
||||||
|
cosmetics, err := s.store.EffectiveCosmeticsForGuild(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
output := &memberListOutput{}
|
output := &memberListOutput{}
|
||||||
output.Body.Members = make([]memberPayload, 0, len(profiles))
|
output.Body.Members = make([]memberPayload, 0, len(profiles))
|
||||||
for _, profile := range profiles {
|
for _, profile := range profiles {
|
||||||
if len(output.Body.Members) >= input.Limit {
|
if len(output.Body.Members) >= input.Limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
output.Body.Members = append(output.Body.Members, memberFromProfile(profile))
|
payload := memberFromProfile(profile)
|
||||||
|
payload.Cosmetics = cosmeticsFromEffective(cosmetics[profile.UserID])
|
||||||
|
output.Body.Members = append(output.Body.Members, payload)
|
||||||
}
|
}
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
@@ -1098,11 +1123,15 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
}, func(ctx context.Context, input *struct {
|
}, func(ctx context.Context, input *struct {
|
||||||
GuildID string `path:"guild_id"`
|
GuildID string `path:"guild_id"`
|
||||||
Body struct {
|
Body struct {
|
||||||
Name string `json:"name" minLength:"1" maxLength:"64"`
|
Name string `json:"name" minLength:"1" maxLength:"64"`
|
||||||
Permissions string `json:"permissions,omitempty"`
|
Permissions string `json:"permissions,omitempty"`
|
||||||
Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
|
Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
|
||||||
Hoist bool `json:"hoist,omitempty"`
|
Hoist bool `json:"hoist,omitempty"`
|
||||||
Mentionable bool `json:"mentionable,omitempty"`
|
Mentionable bool `json:"mentionable,omitempty"`
|
||||||
|
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
|
||||||
|
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
|
||||||
|
NickColor *int64 `json:"nick_color,omitempty"`
|
||||||
|
NickEffect string `json:"nick_effect,omitempty"`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) (*roleOutput, error) {
|
) (*roleOutput, error) {
|
||||||
@@ -1136,6 +1165,19 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
// Оформление роли (AGENT.md 7.4) сохраняем тем же обработчиком правки.
|
||||||
|
if role, err = s.applyRoleCosmetics(ctx, guildID, role, roleCosmeticsInput{
|
||||||
|
FrameID: input.Body.CosmeticFrameID,
|
||||||
|
BadgeID: input.Body.CosmeticBadgeID,
|
||||||
|
NickColor: input.Body.NickColor,
|
||||||
|
NickEffect: input.Body.NickEffect,
|
||||||
|
FrameSet: input.Body.CosmeticFrameID != "",
|
||||||
|
BadgeSet: input.Body.CosmeticBadgeID != "",
|
||||||
|
ColorSet: input.Body.NickColor != nil,
|
||||||
|
EffectSet: input.Body.NickEffect != "",
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "")
|
s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "")
|
||||||
s.dispatchGuildEvent(guildID, "ROLE_CREATE", roleFromStore(*role))
|
s.dispatchGuildEvent(guildID, "ROLE_CREATE", roleFromStore(*role))
|
||||||
@@ -1161,6 +1203,11 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"`
|
Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"`
|
||||||
Hoist *bool `json:"hoist,omitempty"`
|
Hoist *bool `json:"hoist,omitempty"`
|
||||||
Mentionable *bool `json:"mentionable,omitempty"`
|
Mentionable *bool `json:"mentionable,omitempty"`
|
||||||
|
// Оформление роли (AGENT.md 7.4): пустая строка снимает элемент.
|
||||||
|
CosmeticFrameID *string `json:"cosmetic_frame_id,omitempty"`
|
||||||
|
CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"`
|
||||||
|
NickColor *int64 `json:"nick_color,omitempty"`
|
||||||
|
NickEffect *string `json:"nick_effect,omitempty"`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) (*roleOutput, error) {
|
) (*roleOutput, error) {
|
||||||
@@ -1208,6 +1255,19 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
|
||||||
|
if updated, err = s.applyRoleCosmetics(ctx, guildID, updated, roleCosmeticsInput{
|
||||||
|
FrameID: optionalString(input.Body.CosmeticFrameID),
|
||||||
|
BadgeID: optionalString(input.Body.CosmeticBadgeID),
|
||||||
|
NickColor: input.Body.NickColor,
|
||||||
|
NickEffect: optionalString(input.Body.NickEffect),
|
||||||
|
FrameSet: input.Body.CosmeticFrameID != nil,
|
||||||
|
BadgeSet: input.Body.CosmeticBadgeID != nil,
|
||||||
|
ColorSet: input.Body.NickColor != nil,
|
||||||
|
EffectSet: input.Body.NickEffect != nil,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "")
|
s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "")
|
||||||
s.dispatchGuildEvent(guildID, "ROLE_UPDATE", roleFromStore(*updated))
|
s.dispatchGuildEvent(guildID, "ROLE_UPDATE", roleFromStore(*updated))
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ type profilePayload struct {
|
|||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
// LastSeenAt — когда пользователя видели последний раз (для друзей).
|
// LastSeenAt — когда пользователя видели последний раз (для друзей).
|
||||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||||
|
// CreatedAt — дата регистрации: показывается в чужом профиле (AGENT.md 7.2).
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
// Cosmetics — оформление «для друзей»: применяется в DM, друзьях и
|
||||||
|
// глобальных местах (AGENT.md 7.2).
|
||||||
|
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
|
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
|
||||||
@@ -81,6 +86,9 @@ func profileFromUser(user *store.User, includePrivate bool) profilePayload {
|
|||||||
if user.LastSeenAt != nil {
|
if user.LastSeenAt != nil {
|
||||||
payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339)
|
payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
if !user.CreatedAt.IsZero() {
|
||||||
|
payload.CreatedAt = user.CreatedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +367,10 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
|||||||
}
|
}
|
||||||
output := &userOutput{}
|
output := &userOutput{}
|
||||||
output.Body.User = s.profileFromUser(ctx, user, false)
|
output.Body.User = s.profileFromUser(ctx, user, false)
|
||||||
|
// Оформление «для друзей»: в чужом профиле видно глобальный стиль (7.2).
|
||||||
|
if cosmetics, err := s.store.EffectiveCosmeticsForUser(ctx, user.ID); err == nil {
|
||||||
|
output.Body.User.Cosmetics = cosmeticsFromEffective(cosmetics)
|
||||||
|
}
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты оформления профиля (AGENT.md 7.2, 7.4): галерея сервера, оформление
|
||||||
|
// роли, личные стили и поэлементный приоритет с откатом при потере роли.
|
||||||
|
|
||||||
|
// uploadCosmetic загружает рамку или иконку в галерею сервера.
|
||||||
|
func uploadCosmetic(t *testing.T, srv *Server, guildID, kind, name string, cookie *http.Cookie) (id string, status int) {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
if err := writer.WriteField("kind", kind); err != nil {
|
||||||
|
t.Fatalf("multipart kind: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.WriteField("name", name); err != nil {
|
||||||
|
t.Fatalf("multipart name: %v", err)
|
||||||
|
}
|
||||||
|
part, err := writer.CreateFormFile("file", name+".png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("multipart file: %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/guilds/"+guildID+"/cosmetics", &body)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
request.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, request)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
return "", rec.Code
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Cosmetic struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"cosmetic"`
|
||||||
|
}](t, rec)
|
||||||
|
return payload.Cosmetic.ID, rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
type effectiveCosmetics struct {
|
||||||
|
FrameID string `json:"frame_id"`
|
||||||
|
BadgeID string `json:"badge_id"`
|
||||||
|
NickColor *int64 `json:"nick_color"`
|
||||||
|
NickEffect string `json:"nick_effect"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberCosmetics читает вычисленное оформление участника из списка участников.
|
||||||
|
func memberCosmetics(t *testing.T, srv *Server, guildID, userID string, cookie *http.Cookie) effectiveCosmetics {
|
||||||
|
t.Helper()
|
||||||
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", cookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("members = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
list := decodeResponse[struct {
|
||||||
|
Members []struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Cosmetics *effectiveCosmetics `json:"cosmetics"`
|
||||||
|
} `json:"members"`
|
||||||
|
}](t, rec)
|
||||||
|
for _, member := range list.Members {
|
||||||
|
if member.UserID == userID {
|
||||||
|
if member.Cosmetics == nil {
|
||||||
|
return effectiveCosmetics{}
|
||||||
|
}
|
||||||
|
return *member.Cosmetics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("участник %s не найден", userID)
|
||||||
|
return effectiveCosmetics{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cosmeticsFixture — сервер с владельцем и участником.
|
||||||
|
func cosmeticsFixture(t *testing.T) (srv *Server, owner, member *http.Cookie, guildID, memberID string) {
|
||||||
|
t.Helper()
|
||||||
|
srv, owner, member, guildID, memberID = banFixture(t)
|
||||||
|
return srv, owner, member, guildID, memberID
|
||||||
|
}
|
||||||
|
|
||||||
|
// styleScopePath собирает путь личного стиля.
|
||||||
|
func styleScopePath(scope string) string {
|
||||||
|
return "/api/v1/users/@me/styles/" + url.PathEscape(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCosmeticGalleryLifecycle(t *testing.T) {
|
||||||
|
srv, owner, member, guildID, _ := cosmeticsFixture(t)
|
||||||
|
|
||||||
|
frameID, status := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
|
||||||
|
if status != http.StatusOK || frameID == "" {
|
||||||
|
t.Fatalf("загрузка рамки: статус %d", status)
|
||||||
|
}
|
||||||
|
badgeID, status := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner)
|
||||||
|
if status != http.StatusOK || badgeID == "" {
|
||||||
|
t.Fatalf("загрузка иконки: статус %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Галерею видят участники сервера, а не посторонние.
|
||||||
|
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", member)
|
||||||
|
if list.Code != http.StatusOK {
|
||||||
|
t.Fatalf("список галереи = %d (%s)", list.Code, list.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Cosmetics []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"cosmetics"`
|
||||||
|
}](t, list)
|
||||||
|
if len(payload.Cosmetics) != 2 {
|
||||||
|
t.Fatalf("в галерее %d элементов, ожидалось 2", len(payload.Cosmetics))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Участник без MANAGE_ROLES не может пополнять галерею.
|
||||||
|
if _, status := uploadCosmetic(t, srv, guildID, "frame", "Чужая", member); status != http.StatusForbidden {
|
||||||
|
t.Fatalf("загрузка участником: статус %d, ожидался 403", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переименование и удаление.
|
||||||
|
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID,
|
||||||
|
`{"name":"Новый щит"}`, owner)
|
||||||
|
if renamed.Code != http.StatusOK {
|
||||||
|
t.Fatalf("переименование = %d (%s)", renamed.Code, renamed.Body.String())
|
||||||
|
}
|
||||||
|
deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID, "", owner)
|
||||||
|
if deleted.Code != http.StatusOK {
|
||||||
|
t.Fatalf("удаление = %d (%s)", deleted.Code, deleted.Body.String())
|
||||||
|
}
|
||||||
|
after := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", owner)
|
||||||
|
remaining := decodeResponse[struct {
|
||||||
|
Cosmetics []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"cosmetics"`
|
||||||
|
}](t, after)
|
||||||
|
if len(remaining.Cosmetics) != 1 || remaining.Cosmetics[0].ID != frameID {
|
||||||
|
t.Fatalf("после удаления осталось %d элементов", len(remaining.Cosmetics))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleCosmeticsAndPriority(t *testing.T) {
|
||||||
|
srv, owner, member, guildID, memberID := cosmeticsFixture(t)
|
||||||
|
frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
|
||||||
|
badgeID, _ := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner)
|
||||||
|
|
||||||
|
// Роль с рамкой и цветом ника, вторая роль — с иконкой: элементы
|
||||||
|
// складываются поэлементно (AGENT.md 7.2, 7.4).
|
||||||
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
|
||||||
|
`{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`","nick_color":15158332}`, owner)
|
||||||
|
if created.Code != http.StatusOK {
|
||||||
|
t.Fatalf("создание роли = %d (%s)", created.Code, created.Body.String())
|
||||||
|
}
|
||||||
|
role := decodeResponse[struct {
|
||||||
|
Role struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CosmeticFrameID string `json:"cosmetic_frame_id"`
|
||||||
|
NickColor *int64 `json:"nick_color"`
|
||||||
|
} `json:"role"`
|
||||||
|
}](t, created)
|
||||||
|
if role.Role.CosmeticFrameID != frameID || role.Role.NickColor == nil {
|
||||||
|
t.Fatalf("оформление роли не сохранилось: %s", created.Body.String())
|
||||||
|
}
|
||||||
|
second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
|
||||||
|
`{"name":"Щитоносец","cosmetic_badge_id":"`+badgeID+`"}`, owner)
|
||||||
|
roleWithBadge := decodeResponse[struct {
|
||||||
|
Role struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"role"`
|
||||||
|
}](t, second).Role.ID
|
||||||
|
|
||||||
|
for _, roleID := range []string{role.Role.ID, roleWithBadge} {
|
||||||
|
granted := doJSON(t, srv, http.MethodPut,
|
||||||
|
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner)
|
||||||
|
if granted.Code != http.StatusOK {
|
||||||
|
t.Fatalf("выдача роли %s = %d (%s)", roleID, granted.Code, granted.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оформление ролей применяется: рамка, иконка и цвет ника.
|
||||||
|
effective := memberCosmetics(t, srv, guildID, memberID, member)
|
||||||
|
if effective.FrameID != frameID || effective.BadgeID != badgeID {
|
||||||
|
t.Fatalf("оформление ролей: %+v", effective)
|
||||||
|
}
|
||||||
|
if effective.NickColor == nil || *effective.NickColor != 15158332 {
|
||||||
|
t.Fatalf("цвет ника из роли = %v", effective.NickColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Личный стиль добавляет эффект ника, элементы ролей остаются.
|
||||||
|
style := doJSON(t, srv, http.MethodPut, styleScopePath(guildID),
|
||||||
|
`{"frame_id":"`+frameID+`","badge_id":"`+badgeID+`","nick_effect":"glow"}`, member)
|
||||||
|
if style.Code != http.StatusOK {
|
||||||
|
t.Fatalf("личный стиль = %d (%s)", style.Code, style.Body.String())
|
||||||
|
}
|
||||||
|
effective = memberCosmetics(t, srv, guildID, memberID, member)
|
||||||
|
if effective.FrameID != frameID || effective.BadgeID != badgeID || effective.NickEffect != "glow" {
|
||||||
|
t.Fatalf("после личного стиля: %+v", effective)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Потеря роли с рамкой: рамка исчезает, иконка и эффект остаются.
|
||||||
|
revoked := doJSON(t, srv, http.MethodDelete,
|
||||||
|
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+role.Role.ID, "", owner)
|
||||||
|
if revoked.Code != http.StatusOK {
|
||||||
|
t.Fatalf("снятие роли = %d (%s)", revoked.Code, revoked.Body.String())
|
||||||
|
}
|
||||||
|
effective = memberCosmetics(t, srv, guildID, memberID, member)
|
||||||
|
if effective.FrameID != "" {
|
||||||
|
t.Fatalf("рамка осталась после снятия роли: %+v", effective)
|
||||||
|
}
|
||||||
|
if effective.BadgeID != badgeID || effective.NickEffect != "glow" {
|
||||||
|
t.Fatalf("иконка или эффект потерялись: %+v", effective)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Потеря роли с иконкой откатывает и личный элемент: он больше не выдан.
|
||||||
|
if rec := doJSON(t, srv, http.MethodDelete,
|
||||||
|
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleWithBadge, "", owner); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("снятие второй роли = %d", rec.Code)
|
||||||
|
}
|
||||||
|
effective = memberCosmetics(t, srv, guildID, memberID, member)
|
||||||
|
if effective.BadgeID != "" {
|
||||||
|
t.Fatalf("иконка осталась без роли: %+v", effective)
|
||||||
|
}
|
||||||
|
if effective.NickEffect != "glow" {
|
||||||
|
t.Fatalf("эффект ника потерялся: %q", effective.NickEffect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonalStyleAvailability(t *testing.T) {
|
||||||
|
srv, owner, member, guildID, memberID := cosmeticsFixture(t)
|
||||||
|
frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
|
||||||
|
|
||||||
|
// Рамка не выдана ролями: выбрать её нельзя.
|
||||||
|
if rec := doJSON(t, srv, http.MethodPut, styleScopePath(guildID),
|
||||||
|
`{"frame_id":"`+frameID+`"}`, member); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("недоступная рамка = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// Цвет вне палитры отклоняется.
|
||||||
|
if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"),
|
||||||
|
`{"nick_color":1}`, member); rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("цвет вне палитры = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// Неизвестный эффект отклоняется.
|
||||||
|
if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"),
|
||||||
|
`{"nick_effect":"rainbow"}`, member); rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("неизвестный эффект = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выдаём роль с рамкой и иконкой.
|
||||||
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
|
||||||
|
`{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`"}`, owner)
|
||||||
|
roleID := decodeResponse[struct {
|
||||||
|
Role struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"role"`
|
||||||
|
}](t, created).Role.ID
|
||||||
|
if rec := doJSON(t, srv, http.MethodPut,
|
||||||
|
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("выдача роли = %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Теперь рамка доступна и в пер-серверном, и в глобальном стиле.
|
||||||
|
for _, scope := range []string{guildID, "global"} {
|
||||||
|
rec := doJSON(t, srv, http.MethodPut, styleScopePath(scope),
|
||||||
|
`{"frame_id":"`+frameID+`","nick_effect":"gradient"}`, member)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("стиль %s = %d (%s)", scope, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список стилей отдаёт доступные элементы и встроенный набор эффектов.
|
||||||
|
styles := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/styles", "", member)
|
||||||
|
list := decodeResponse[struct {
|
||||||
|
Styles []struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
FrameIDs []string `json:"frame_ids"`
|
||||||
|
Effects []string `json:"effects"`
|
||||||
|
} `json:"styles"`
|
||||||
|
}](t, styles)
|
||||||
|
if len(list.Styles) != 2 {
|
||||||
|
t.Fatalf("стилей %d, ожидалось 2", len(list.Styles))
|
||||||
|
}
|
||||||
|
if len(list.Styles[0].FrameIDs) != 1 || list.Styles[0].FrameIDs[0] != frameID {
|
||||||
|
t.Fatalf("доступные рамки: %+v", list.Styles[0].FrameIDs)
|
||||||
|
}
|
||||||
|
if len(list.Styles[0].Effects) != len(store.NickEffects()) {
|
||||||
|
t.Fatalf("эффектов %d", len(list.Styles[0].Effects))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сброс стиля: глобальный стиль больше не применяется.
|
||||||
|
if rec := doJSON(t, srv, http.MethodDelete, styleScopePath("global"), "", member); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("сброс стиля = %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,6 +153,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerVoiceRoutes(s.api)
|
s.registerVoiceRoutes(s.api)
|
||||||
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.registerVoiceWebhook(apiRouter)
|
s.registerVoiceWebhook(apiRouter)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Оформление профиля (AGENT.md 7.2, 7.4): галерея сервера (рамки и иконки),
|
||||||
|
// личные стили пользователя и вычисленное оформление для контекста сервера.
|
||||||
|
|
||||||
|
// CosmeticKind — вид элемента галереи.
|
||||||
|
type CosmeticKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CosmeticFrame — рамка вокруг аватара и ника.
|
||||||
|
CosmeticFrame CosmeticKind = "frame"
|
||||||
|
// CosmeticBadge — иконка рядом с ником.
|
||||||
|
CosmeticBadge CosmeticKind = "badge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NickEffect — встроенный эффект ника (AGENT.md 7.4).
|
||||||
|
type NickEffect string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NickEffectNone NickEffect = "none"
|
||||||
|
NickEffectGradient NickEffect = "gradient"
|
||||||
|
NickEffectGlow NickEffect = "glow"
|
||||||
|
NickEffectFire NickEffect = "fire"
|
||||||
|
NickEffectShimmer NickEffect = "shimmer"
|
||||||
|
NickEffectGlitch NickEffect = "glitch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nickEffects — допустимые эффекты: набор встроенный, загружать их нельзя.
|
||||||
|
var nickEffects = []NickEffect{
|
||||||
|
NickEffectNone, NickEffectGradient, NickEffectGlow,
|
||||||
|
NickEffectFire, NickEffectShimmer, NickEffectGlitch,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidNickEffect сообщает, что эффект входит во встроенный набор.
|
||||||
|
func ValidNickEffect(effect string) bool {
|
||||||
|
for _, known := range nickEffects {
|
||||||
|
if string(known) == effect {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NickEffects возвращает встроенный набор эффектов (для палитры в настройках).
|
||||||
|
func NickEffects() []NickEffect {
|
||||||
|
result := make([]NickEffect, len(nickEffects))
|
||||||
|
copy(result, nickEffects)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// nickPalette — палитра цветов ника: выбирать можно только из неё (AGENT.md 7.4).
|
||||||
|
var nickPalette = []int64{
|
||||||
|
0xf2f3f5, 0xa3a6ad, 0xe74c3c, 0xe67e22, 0xf1c40f, 0x2ecc71,
|
||||||
|
0x1abc9c, 0x3498db, 0x5865f2, 0x9b59b6, 0xe91e63, 0xff7ab8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// NickColorPalette возвращает палитру цветов ника.
|
||||||
|
func NickColorPalette() []int64 {
|
||||||
|
result := make([]int64, len(nickPalette))
|
||||||
|
copy(result, nickPalette)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// InNickColorPalette сообщает, что цвет входит в палитру.
|
||||||
|
func InNickColorPalette(color int64) bool {
|
||||||
|
for _, known := range nickPalette {
|
||||||
|
if known == color {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuildCosmetic — элемент галереи оформления сервера.
|
||||||
|
type GuildCosmetic struct {
|
||||||
|
ID uint64
|
||||||
|
GuildID uint64
|
||||||
|
Kind CosmeticKind
|
||||||
|
Name string
|
||||||
|
FileID uint64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGuildCosmeticParams — параметры нового элемента галереи.
|
||||||
|
type CreateGuildCosmeticParams struct {
|
||||||
|
ID uint64
|
||||||
|
GuildID uint64
|
||||||
|
Kind CosmeticKind
|
||||||
|
Name string
|
||||||
|
FileID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
const cosmeticColumns = `id, guild_id, kind, name, file_id, created_at`
|
||||||
|
|
||||||
|
// CreateGuildCosmetic добавляет рамку или иконку в галерею сервера.
|
||||||
|
func (s *Store) CreateGuildCosmetic(ctx context.Context, params CreateGuildCosmeticParams) (*GuildCosmetic, error) {
|
||||||
|
if params.ID == 0 {
|
||||||
|
params.ID = s.NextID()
|
||||||
|
}
|
||||||
|
_, err := s.writer.ExecContext(ctx, `
|
||||||
|
INSERT INTO guild_cosmetics (id, guild_id, kind, name, file_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
int64(params.ID), int64(params.GuildID), string(params.Kind), params.Name,
|
||||||
|
int64(params.FileID), s.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetGuildCosmetic(ctx, params.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetGuildCosmetic(ctx context.Context, id uint64) (*GuildCosmetic, error) {
|
||||||
|
row := s.reader.QueryRowContext(ctx, `SELECT `+cosmeticColumns+` FROM guild_cosmetics WHERE id = ?`, int64(id))
|
||||||
|
return scanCosmetic(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGuildCosmetics возвращает галерею сервера: сначала рамки, потом иконки.
|
||||||
|
func (s *Store) ListGuildCosmetics(ctx context.Context, guildID uint64) ([]GuildCosmetic, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx,
|
||||||
|
`SELECT `+cosmeticColumns+` FROM guild_cosmetics WHERE guild_id = ? ORDER BY kind, id`,
|
||||||
|
int64(guildID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cosmetics := make([]GuildCosmetic, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
cosmetic, err := scanCosmetic(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cosmetics = append(cosmetics, *cosmetic)
|
||||||
|
}
|
||||||
|
return cosmetics, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountGuildCosmetics считает элементы галереи: лимит 50 на сервер (7.4).
|
||||||
|
func (s *Store) CountGuildCosmetics(ctx context.Context, guildID uint64) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM guild_cosmetics WHERE guild_id = ?`, int64(guildID)).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameGuildCosmetic меняет имя элемента галереи.
|
||||||
|
func (s *Store) RenameGuildCosmetic(ctx context.Context, id uint64, name string) (*GuildCosmetic, error) {
|
||||||
|
result, err := s.writer.ExecContext(ctx,
|
||||||
|
`UPDATE guild_cosmetics SET name = ? WHERE id = ?`, name, int64(id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.GetGuildCosmetic(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGuildCosmetic удаляет элемент галереи. Ссылки из ролей и личных стилей
|
||||||
|
// снимаются базой (ON DELETE SET NULL), поэтому оформление откатывается к
|
||||||
|
// следующему приоритету и не ломается.
|
||||||
|
func (s *Store) DeleteGuildCosmetic(ctx context.Context, id uint64) error {
|
||||||
|
result, err := s.writer.ExecContext(ctx, `DELETE FROM guild_cosmetics WHERE id = ?`, int64(id))
|
||||||
|
if err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCosmetic(scanner interface{ Scan(...any) error }) (*GuildCosmetic, error) {
|
||||||
|
var (
|
||||||
|
cosmetic GuildCosmetic
|
||||||
|
kind string
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
if err := scanner.Scan(&cosmetic.ID, &cosmetic.GuildID, &kind, &cosmetic.Name,
|
||||||
|
&cosmetic.FileID, &createdAt); err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
cosmetic.Kind = CosmeticKind(kind)
|
||||||
|
cosmetic.CreatedAt = parseTimestamp(createdAt)
|
||||||
|
return &cosmetic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserStyle — личный стиль профиля: guild_id == nil — «для друзей».
|
||||||
|
type UserStyle struct {
|
||||||
|
ID uint64
|
||||||
|
UserID uint64
|
||||||
|
GuildID *uint64
|
||||||
|
FrameID *uint64
|
||||||
|
BadgeID *uint64
|
||||||
|
NickColor *int64
|
||||||
|
NickEffect string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertUserStyleParams — новое состояние стиля: элементы передаются целиком,
|
||||||
|
// чтобы снятие было обычной записью пустого значения.
|
||||||
|
type UpsertUserStyleParams struct {
|
||||||
|
UserID uint64
|
||||||
|
GuildID *uint64
|
||||||
|
FrameID *uint64
|
||||||
|
BadgeID *uint64
|
||||||
|
NickColor *int64
|
||||||
|
NickEffect string
|
||||||
|
}
|
||||||
|
|
||||||
|
const userStyleColumns = `id, user_id, guild_id, frame_id, badge_id, nick_color, nick_effect, updated_at`
|
||||||
|
|
||||||
|
// UpsertUserStyle записывает стиль пользователя для области (nil — «для друзей»).
|
||||||
|
func (s *Store) UpsertUserStyle(ctx context.Context, params UpsertUserStyleParams) (*UserStyle, error) {
|
||||||
|
effect := params.NickEffect
|
||||||
|
if effect == "" {
|
||||||
|
effect = string(NickEffectNone)
|
||||||
|
}
|
||||||
|
_, err := s.writer.ExecContext(ctx, `
|
||||||
|
INSERT INTO user_styles (user_id, guild_id, frame_id, badge_id, nick_color, nick_effect, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (user_id, IFNULL(guild_id, 0)) DO UPDATE SET
|
||||||
|
frame_id = excluded.frame_id,
|
||||||
|
badge_id = excluded.badge_id,
|
||||||
|
nick_color = excluded.nick_color,
|
||||||
|
nick_effect = excluded.nick_effect,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
int64(params.UserID), nullableID(params.GuildID), nullableID(params.FrameID),
|
||||||
|
nullableID(params.BadgeID), nullableNickColor(params.NickColor), effect, s.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
return s.GetUserStyle(ctx, params.UserID, params.GuildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserStyle читает стиль пользователя для области; отсутствие стиля — не
|
||||||
|
// ошибка: возвращается `nil` без стиля.
|
||||||
|
func (s *Store) GetUserStyle(ctx context.Context, userID uint64, guildID *uint64) (*UserStyle, error) {
|
||||||
|
row := s.reader.QueryRowContext(ctx,
|
||||||
|
`SELECT `+userStyleColumns+` FROM user_styles
|
||||||
|
WHERE user_id = ? AND IFNULL(guild_id, 0) = ?`,
|
||||||
|
int64(userID), int64(nullableScope(guildID)))
|
||||||
|
style, err := scanUserStyle(row)
|
||||||
|
if err != nil {
|
||||||
|
// Отсутствие стиля — не ошибка: оформление просто не задано.
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return style, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserStyles возвращает все стили пользователя: «для друзей» и пер-серверные.
|
||||||
|
func (s *Store) ListUserStyles(ctx context.Context, userID uint64) ([]UserStyle, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx,
|
||||||
|
`SELECT `+userStyleColumns+` FROM user_styles WHERE user_id = ? ORDER BY IFNULL(guild_id, 0)`,
|
||||||
|
int64(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
styles := make([]UserStyle, 0, 4)
|
||||||
|
for rows.Next() {
|
||||||
|
style, err := scanUserStyle(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
styles = append(styles, *style)
|
||||||
|
}
|
||||||
|
return styles, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserStyle убирает стиль области: оформление откатывается к следующему
|
||||||
|
// приоритету (AGENT.md 7.2).
|
||||||
|
func (s *Store) DeleteUserStyle(ctx context.Context, userID uint64, guildID *uint64) error {
|
||||||
|
if _, err := s.writer.ExecContext(ctx,
|
||||||
|
`DELETE FROM user_styles WHERE user_id = ? AND IFNULL(guild_id, 0) = ?`,
|
||||||
|
int64(userID), int64(nullableScope(guildID))); err != nil {
|
||||||
|
return mapError(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUserStyle(scanner interface{ Scan(...any) error }) (*UserStyle, error) {
|
||||||
|
var (
|
||||||
|
style UserStyle
|
||||||
|
guildID sql.NullInt64
|
||||||
|
frameID sql.NullInt64
|
||||||
|
badgeID sql.NullInt64
|
||||||
|
nickColor sql.NullInt64
|
||||||
|
updatedAt string
|
||||||
|
)
|
||||||
|
if err := scanner.Scan(&style.ID, &style.UserID, &guildID, &frameID, &badgeID,
|
||||||
|
&nickColor, &style.NickEffect, &updatedAt); err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
style.GuildID = optionalID(guildID)
|
||||||
|
style.FrameID = optionalID(frameID)
|
||||||
|
style.BadgeID = optionalID(badgeID)
|
||||||
|
if nickColor.Valid {
|
||||||
|
value := nickColor.Int64
|
||||||
|
style.NickColor = &value
|
||||||
|
}
|
||||||
|
style.UpdatedAt = parseTimestamp(updatedAt)
|
||||||
|
return &style, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableScope(guildID *uint64) uint64 {
|
||||||
|
if guildID == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *guildID
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableNickColor(value *int64) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveCosmetics — вычисленное оформление участника в контексте сервера
|
||||||
|
// (или «для друзей», если GuildID == nil).
|
||||||
|
type EffectiveCosmetics struct {
|
||||||
|
FrameID *uint64
|
||||||
|
BadgeID *uint64
|
||||||
|
NickColor *int64
|
||||||
|
NickEffect string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty сообщает, что элементов оформления нет.
|
||||||
|
func (c EffectiveCosmetics) IsEmpty() bool {
|
||||||
|
return c.FrameID == nil && c.BadgeID == nil && c.NickColor == nil &&
|
||||||
|
(c.NickEffect == "" || c.NickEffect == string(NickEffectNone))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CosmeticGrants — элементы, доступные пользователю: из ролей этого сервера и
|
||||||
|
// из ролей на любом сервере (для стиля «для друзей»).
|
||||||
|
type CosmeticGrants struct {
|
||||||
|
Frames map[uint64]bool
|
||||||
|
Badges map[uint64]bool
|
||||||
|
GuildFrames map[uint64]bool
|
||||||
|
GuildBadges map[uint64]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveCosmeticsForGuild вычисляет оформление всех участников сервера
|
||||||
|
// поэлементно: роль (высшая с заданным элементом) → пер-серверный стиль →
|
||||||
|
// стиль «для друзей» → дефолт (AGENT.md 7.2).
|
||||||
|
func (s *Store) EffectiveCosmeticsForGuild(ctx context.Context, guildID uint64) (map[uint64]EffectiveCosmetics, error) {
|
||||||
|
// 1. Роли сервера: позиция задаёт приоритет, оформление роли — источник
|
||||||
|
// элементов на этом сервере.
|
||||||
|
roles, err := s.ListGuildRoles(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roleByID := make(map[uint64]Role, len(roles))
|
||||||
|
for _, role := range roles {
|
||||||
|
roleByID[role.ID] = role
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Роли участников сервера.
|
||||||
|
members, err := s.ListGuildMemberProfiles(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roleIDsByUser := make(map[uint64][]uint64, len(members))
|
||||||
|
userIDs := make([]uint64, 0, len(members))
|
||||||
|
for _, member := range members {
|
||||||
|
roleIDsByUser[member.UserID] = member.RoleIDs
|
||||||
|
userIDs = append(userIDs, member.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Личные стили участников: пер-серверные и «для друзей».
|
||||||
|
styles, err := s.listUserStylesFor(ctx, userIDs, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Доступность элементов личных стилей: рамку или иконку можно выбрать,
|
||||||
|
// только если её выдаёт роль (7.2).
|
||||||
|
grants, err := s.CosmeticGrantsForUsers(ctx, userIDs, &guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[uint64]EffectiveCosmetics, len(members))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
result[userID] = resolveCosmetics(
|
||||||
|
roleIDsByUser[userID], roleByID,
|
||||||
|
styles.guild[userID], styles.global[userID],
|
||||||
|
grants[userID],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveCosmeticsForUser вычисляет оформление пользователя в контексте
|
||||||
|
// «для друзей» (DM, список друзей, глобальные места).
|
||||||
|
func (s *Store) EffectiveCosmeticsForUser(ctx context.Context, userID uint64) (EffectiveCosmetics, error) {
|
||||||
|
style, err := s.GetUserStyle(ctx, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return EffectiveCosmetics{}, err
|
||||||
|
}
|
||||||
|
grants, err := s.CosmeticGrantsForUsers(ctx, []uint64{userID}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return EffectiveCosmetics{}, err
|
||||||
|
}
|
||||||
|
return resolveCosmetics(nil, nil, nil, style, grants[userID]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type userStyles struct {
|
||||||
|
guild map[uint64]*UserStyle
|
||||||
|
global map[uint64]*UserStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUserStylesFor читает стили пачки пользователей одним запросом.
|
||||||
|
func (s *Store) listUserStylesFor(ctx context.Context, userIDs []uint64, guildID uint64) (userStyles, error) {
|
||||||
|
result := userStyles{guild: map[uint64]*UserStyle{}, global: map[uint64]*UserStyle{}}
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
placeholders := make([]byte, 0, len(userIDs)*2)
|
||||||
|
args := make([]any, 0, len(userIDs)+1)
|
||||||
|
args = append(args, int64(guildID))
|
||||||
|
for i, userID := range userIDs {
|
||||||
|
if i > 0 {
|
||||||
|
placeholders = append(placeholders, ',')
|
||||||
|
}
|
||||||
|
placeholders = append(placeholders, '?')
|
||||||
|
args = append(args, int64(userID))
|
||||||
|
}
|
||||||
|
rows, err := s.reader.QueryContext(ctx,
|
||||||
|
`SELECT `+userStyleColumns+` FROM user_styles
|
||||||
|
WHERE IFNULL(guild_id, 0) IN (0, ?) AND user_id IN (`+string(placeholders)+`)`,
|
||||||
|
args...)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
style, err := scanUserStyle(rows)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if style.GuildID == nil {
|
||||||
|
result.global[style.UserID] = style
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.guild[style.UserID] = style
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGrantedCosmetics возвращает элементы галереи, выданные ролями
|
||||||
|
// пользователя на любом сервере: из них собирается выбор в настройках (7.2).
|
||||||
|
func (s *Store) ListGrantedCosmetics(ctx context.Context, userID uint64) ([]GuildCosmetic, error) {
|
||||||
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT c.`+strings.ReplaceAll(cosmeticColumns, ", ", ", c.")+`
|
||||||
|
FROM guild_cosmetics c
|
||||||
|
JOIN roles r ON r.cosmetic_frame_id = c.id OR r.cosmetic_badge_id = c.id
|
||||||
|
JOIN member_roles mr ON mr.role_id = r.id AND mr.user_id = ?
|
||||||
|
ORDER BY c.kind, c.id`, int64(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cosmetics := make([]GuildCosmetic, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
cosmetic, err := scanCosmetic(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cosmetics = append(cosmetics, *cosmetic)
|
||||||
|
}
|
||||||
|
return cosmetics, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CosmeticGrantsForUsers собирает доступные рамки и иконки: из ролей указанного
|
||||||
|
// сервера (guildOnly != nil) и из ролей на любом сервере (для «для друзей»).
|
||||||
|
func (s *Store) CosmeticGrantsForUsers(ctx context.Context, userIDs []uint64, guildID *uint64) (map[uint64]CosmeticGrants, error) {
|
||||||
|
result := make(map[uint64]CosmeticGrants, len(userIDs))
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
placeholders := make([]byte, 0, len(userIDs)*2)
|
||||||
|
args := make([]any, 0, len(userIDs))
|
||||||
|
for i, userID := range userIDs {
|
||||||
|
if i > 0 {
|
||||||
|
placeholders = append(placeholders, ',')
|
||||||
|
}
|
||||||
|
placeholders = append(placeholders, '?')
|
||||||
|
args = append(args, int64(userID))
|
||||||
|
}
|
||||||
|
// Права на оформление дают роли, где задана рамка или иконка.
|
||||||
|
query := `SELECT mr.user_id, r.guild_id, r.cosmetic_frame_id, r.cosmetic_badge_id
|
||||||
|
FROM member_roles mr
|
||||||
|
JOIN roles r ON r.id = mr.role_id
|
||||||
|
WHERE mr.user_id IN (` + string(placeholders) + `)
|
||||||
|
AND (r.cosmetic_frame_id IS NOT NULL OR r.cosmetic_badge_id IS NOT NULL)`
|
||||||
|
rows, err := s.reader.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
userID uint64
|
||||||
|
roleGuild uint64
|
||||||
|
frameID sql.NullInt64
|
||||||
|
badgeID sql.NullInt64
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&userID, &roleGuild, &frameID, &badgeID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry, ok := result[userID]
|
||||||
|
if !ok {
|
||||||
|
entry = CosmeticGrants{
|
||||||
|
Frames: map[uint64]bool{},
|
||||||
|
Badges: map[uint64]bool{},
|
||||||
|
GuildFrames: map[uint64]bool{},
|
||||||
|
GuildBadges: map[uint64]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sameGuild := guildID != nil && roleGuild == *guildID
|
||||||
|
if frameID.Valid {
|
||||||
|
entry.Frames[uint64(frameID.Int64)] = true
|
||||||
|
if sameGuild {
|
||||||
|
entry.GuildFrames[uint64(frameID.Int64)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if badgeID.Valid {
|
||||||
|
entry.Badges[uint64(badgeID.Int64)] = true
|
||||||
|
if sameGuild {
|
||||||
|
entry.GuildBadges[uint64(badgeID.Int64)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[userID] = entry
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Пользователи без ролей с оформлением всё равно должны быть в ответе.
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if _, ok := result[userID]; !ok {
|
||||||
|
result[userID] = CosmeticGrants{
|
||||||
|
Frames: map[uint64]bool{},
|
||||||
|
Badges: map[uint64]bool{},
|
||||||
|
GuildFrames: map[uint64]bool{},
|
||||||
|
GuildBadges: map[uint64]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCosmetics применяет поэлементный приоритет оформления.
|
||||||
|
func resolveCosmetics(
|
||||||
|
roleIDs []uint64,
|
||||||
|
roleByID map[uint64]Role,
|
||||||
|
guildStyle *UserStyle,
|
||||||
|
globalStyle *UserStyle,
|
||||||
|
grants CosmeticGrants,
|
||||||
|
) EffectiveCosmetics {
|
||||||
|
// Роли идут от высшей к низшей: первая с заданным элементом побеждает.
|
||||||
|
roles := make([]Role, 0, len(roleIDs))
|
||||||
|
for _, roleID := range roleIDs {
|
||||||
|
if role, ok := roleByID[roleID]; ok {
|
||||||
|
roles = append(roles, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RoleIDs приходят по возрастанию id; порядок по позиции восстанавливаем сами.
|
||||||
|
sort.SliceStable(roles, func(i, j int) bool { return roles[i].Position > roles[j].Position })
|
||||||
|
|
||||||
|
resolved := EffectiveCosmetics{NickEffect: string(NickEffectNone)}
|
||||||
|
for _, role := range roles {
|
||||||
|
if resolved.FrameID == nil && role.CosmeticFrameID != nil {
|
||||||
|
resolved.FrameID = role.CosmeticFrameID
|
||||||
|
}
|
||||||
|
if resolved.BadgeID == nil && role.CosmeticBadgeID != nil {
|
||||||
|
resolved.BadgeID = role.CosmeticBadgeID
|
||||||
|
}
|
||||||
|
if resolved.NickColor == nil && role.NickColor != nil {
|
||||||
|
resolved.NickColor = role.NickColor
|
||||||
|
}
|
||||||
|
if resolved.NickEffect == string(NickEffectNone) && role.NickEffect != "" &&
|
||||||
|
role.NickEffect != string(NickEffectNone) {
|
||||||
|
effect := role.NickEffect
|
||||||
|
resolved.NickEffect = effect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Личные стили: пер-серверный важнее «для друзей», элементы проверяются на
|
||||||
|
// доступность — потеря роли откатывает их к следующему приоритету (7.2).
|
||||||
|
if guildStyle != nil {
|
||||||
|
if resolved.FrameID == nil && allowed(guildStyle.FrameID, grants.GuildFrames) {
|
||||||
|
resolved.FrameID = guildStyle.FrameID
|
||||||
|
}
|
||||||
|
if resolved.BadgeID == nil && allowed(guildStyle.BadgeID, grants.GuildBadges) {
|
||||||
|
resolved.BadgeID = guildStyle.BadgeID
|
||||||
|
}
|
||||||
|
if resolved.NickColor == nil {
|
||||||
|
resolved.NickColor = guildStyle.NickColor
|
||||||
|
}
|
||||||
|
if resolved.NickEffect == string(NickEffectNone) && guildStyle.NickEffect != "" {
|
||||||
|
resolved.NickEffect = guildStyle.NickEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if globalStyle != nil {
|
||||||
|
if resolved.FrameID == nil && allowed(globalStyle.FrameID, grants.Frames) {
|
||||||
|
resolved.FrameID = globalStyle.FrameID
|
||||||
|
}
|
||||||
|
if resolved.BadgeID == nil && allowed(globalStyle.BadgeID, grants.Badges) {
|
||||||
|
resolved.BadgeID = globalStyle.BadgeID
|
||||||
|
}
|
||||||
|
if resolved.NickColor == nil {
|
||||||
|
resolved.NickColor = globalStyle.NickColor
|
||||||
|
}
|
||||||
|
if resolved.NickEffect == string(NickEffectNone) && globalStyle.NickEffect != "" {
|
||||||
|
resolved.NickEffect = globalStyle.NickEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowed(id *uint64, granted map[uint64]bool) bool {
|
||||||
|
if id == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return granted[*id]
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { ApiError, isApiErrorPayload, parsePayload, request } from './client';
|
||||||
|
import type { MemberCosmetics } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оформление профиля (AGENT.md §7.2, §7.4): галерея сервера (рамки и иконки),
|
||||||
|
* оформление ролей и личные стили пользователя. Сервер отдаёт вычисленное
|
||||||
|
* оформление в списке участников, здесь — управление галереей и стилями.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CosmeticKind = 'frame' | 'badge';
|
||||||
|
|
||||||
|
export interface GuildCosmetic {
|
||||||
|
id: string;
|
||||||
|
guild_id: string;
|
||||||
|
kind: CosmeticKind;
|
||||||
|
name: string;
|
||||||
|
file_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лимиты сервера: галерея ≤ 50 элементов, файл ≤ 5 МБ (AGENT.md 7.4). */
|
||||||
|
export const COSMETIC_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
export const COSMETICS_MAX_PER_GUILD = 50;
|
||||||
|
export const COSMETIC_NAME_MAX = 40;
|
||||||
|
|
||||||
|
/** Встроенный набор эффектов ника; порядок совпадает с серверным. */
|
||||||
|
export const NICK_EFFECTS = ['none', 'gradient', 'glow', 'fire', 'shimmer', 'glitch'] as const;
|
||||||
|
export type NickEffect = (typeof NICK_EFFECTS)[number];
|
||||||
|
|
||||||
|
export const guildCosmeticsQueryKey = (guildId: string) =>
|
||||||
|
['guilds', guildId, 'cosmetics'] as const;
|
||||||
|
export const myStylesQueryKey = ['users', '@me', 'styles'] as const;
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCosmetic(value: unknown): GuildCosmetic | null {
|
||||||
|
const record = asRecord(value);
|
||||||
|
const id = asString(record?.['id']);
|
||||||
|
const fileId = asString(record?.['file_id']);
|
||||||
|
const kind = record?.['kind'];
|
||||||
|
if (record === null || id === undefined || fileId === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (kind !== 'frame' && kind !== 'badge') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
guild_id: asString(record['guild_id']) ?? '',
|
||||||
|
kind,
|
||||||
|
name: asString(record['name']) ?? kind,
|
||||||
|
file_id: fileId,
|
||||||
|
created_at: asString(record['created_at']) ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCosmetics(payload: unknown): GuildCosmetic[] {
|
||||||
|
const record = asRecord(payload);
|
||||||
|
const list = record?.['cosmetics'];
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return list.map(parseCosmetic).filter((item): item is GuildCosmetic => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /guilds/{id}/cosmetics` — галерея сервера. */
|
||||||
|
export async function fetchGuildCosmetics(
|
||||||
|
guildId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<GuildCosmetic[]> {
|
||||||
|
const payload = await request<unknown>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/cosmetics`,
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
return parseCosmetics(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /guilds/{id}/cosmetics` — загрузка рамки или иконки (multipart, право
|
||||||
|
* MANAGE_ROLES). Тело — форма, поэтому запрос собирается напрямую.
|
||||||
|
*/
|
||||||
|
export async function uploadGuildCosmetic(
|
||||||
|
guildId: string,
|
||||||
|
input: { kind: CosmeticKind; name: string; file: File },
|
||||||
|
): Promise<GuildCosmetic> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('kind', input.kind);
|
||||||
|
form.append('name', input.name);
|
||||||
|
form.append('file', input.file);
|
||||||
|
const response = await fetch(`/api/v1/guilds/${encodeURIComponent(guildId)}/cosmetics`, {
|
||||||
|
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 = asRecord(payload)?.['error'];
|
||||||
|
if (isApiErrorPayload(envelope)) {
|
||||||
|
throw new ApiError(response.status, envelope.code, envelope.message, envelope.details);
|
||||||
|
}
|
||||||
|
throw new ApiError(response.status, 'internal.error', 'unexpected response');
|
||||||
|
}
|
||||||
|
const cosmetic = parseCosmetic(asRecord(payload)?.['cosmetic']);
|
||||||
|
if (cosmetic === null) {
|
||||||
|
throw new ApiError(response.status, 'internal.error', 'unexpected response');
|
||||||
|
}
|
||||||
|
return cosmetic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PATCH /guilds/{id}/cosmetics/{cid}` — переименовать элемент галереи. */
|
||||||
|
export async function renameGuildCosmetic(
|
||||||
|
guildId: string,
|
||||||
|
cosmeticId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<GuildCosmetic> {
|
||||||
|
const payload = await request<unknown>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/cosmetics/${encodeURIComponent(cosmeticId)}`,
|
||||||
|
{ method: 'PATCH', body: { name } },
|
||||||
|
);
|
||||||
|
const cosmetic = parseCosmetic(asRecord(payload)?.['cosmetic']);
|
||||||
|
if (cosmetic === null) {
|
||||||
|
throw new Error('malformed cosmetic payload');
|
||||||
|
}
|
||||||
|
return cosmetic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /guilds/{id}/cosmetics/{cid}` — убрать элемент из галереи. */
|
||||||
|
export async function deleteGuildCosmetic(guildId: string, cosmeticId: string): Promise<void> {
|
||||||
|
await request(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/cosmetics/${encodeURIComponent(cosmeticId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Личный стиль профиля вместе с доступными элементами. */
|
||||||
|
export interface UserStyle {
|
||||||
|
/** `global` — «для друзей», иначе идентификатор сервера. */
|
||||||
|
scope: string;
|
||||||
|
guild_id?: string;
|
||||||
|
/** Имя сервера для подписи области (сервер отдаёт его вместе со стилем). */
|
||||||
|
guild_name?: string;
|
||||||
|
style: MemberCosmetics;
|
||||||
|
frame_ids: string[];
|
||||||
|
badge_ids: string[];
|
||||||
|
nick_colors: number[];
|
||||||
|
effects: NickEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyleInput {
|
||||||
|
frame_id?: string;
|
||||||
|
badge_id?: string;
|
||||||
|
nick_color?: number;
|
||||||
|
nick_effect?: NickEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStyle(value: unknown): UserStyle | null {
|
||||||
|
const record = asRecord(value);
|
||||||
|
const scope = asString(record?.['scope']);
|
||||||
|
if (record === null || scope === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const style = asRecord(record['style']) ?? {};
|
||||||
|
const cosmetics: MemberCosmetics = {};
|
||||||
|
const frame = asString(style['frame_id']);
|
||||||
|
const badge = asString(style['badge_id']);
|
||||||
|
const color = asNumber(style['nick_color']);
|
||||||
|
const effect = asString(style['nick_effect']);
|
||||||
|
if (frame !== undefined) {
|
||||||
|
cosmetics.frame_id = frame;
|
||||||
|
}
|
||||||
|
if (badge !== undefined) {
|
||||||
|
cosmetics.badge_id = badge;
|
||||||
|
}
|
||||||
|
if (color !== undefined) {
|
||||||
|
cosmetics.nick_color = color;
|
||||||
|
}
|
||||||
|
if (effect !== undefined) {
|
||||||
|
cosmetics.nick_effect = effect;
|
||||||
|
}
|
||||||
|
const stringList = (input: unknown): string[] =>
|
||||||
|
Array.isArray(input) ? input.filter((item): item is string => typeof item === 'string') : [];
|
||||||
|
const numberList = (input: unknown): number[] =>
|
||||||
|
Array.isArray(input) ? input.filter((item): item is number => typeof item === 'number') : [];
|
||||||
|
const effectList = (input: unknown): NickEffect[] =>
|
||||||
|
Array.isArray(input)
|
||||||
|
? input.filter((item): item is NickEffect => NICK_EFFECTS.includes(item as NickEffect))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const parsed: UserStyle = {
|
||||||
|
scope,
|
||||||
|
style: cosmetics,
|
||||||
|
frame_ids: stringList(record['frame_ids']),
|
||||||
|
badge_ids: stringList(record['badge_ids']),
|
||||||
|
nick_colors: numberList(record['nick_colors']),
|
||||||
|
effects: effectList(record['effects']),
|
||||||
|
};
|
||||||
|
const guildId = asString(record['guild_id']);
|
||||||
|
const guildName = asString(record['guild_name']);
|
||||||
|
if (guildId !== undefined) {
|
||||||
|
parsed.guild_id = guildId;
|
||||||
|
}
|
||||||
|
if (guildName !== undefined) {
|
||||||
|
parsed.guild_name = guildName;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /users/@me/styles` — личные стили и доступные элементы. */
|
||||||
|
export async function fetchMyStyles(signal?: AbortSignal): Promise<UserStyle[]> {
|
||||||
|
const payload = await request<unknown>(
|
||||||
|
'/users/@me/styles',
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
const list = asRecord(payload)?.['styles'];
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return list.map(parseStyle).filter((item): item is UserStyle => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PUT /users/@me/styles/{scope}` — задать стиль области. */
|
||||||
|
export async function putMyStyle(scope: string, input: StyleInput): Promise<UserStyle> {
|
||||||
|
const payload = await request<unknown>(`/users/@me/styles/${encodeURIComponent(scope)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
const style = parseStyle(asRecord(payload)?.['style']);
|
||||||
|
if (style === null) {
|
||||||
|
throw new Error('malformed style payload');
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /users/@me/styles/{scope}` — сбросить стиль области. */
|
||||||
|
export async function deleteMyStyle(scope: string): Promise<void> {
|
||||||
|
await request(`/users/@me/styles/${encodeURIComponent(scope)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
@@ -73,6 +73,30 @@ export async function fetchRoles(guildId: string, signal?: AbortSignal): Promise
|
|||||||
return payload.roles;
|
return payload.roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Оформление роли в запросе правки (AGENT.md 7.4). */
|
||||||
|
export interface RoleDecorationUpdate {
|
||||||
|
cosmetic_frame_id?: string;
|
||||||
|
cosmetic_badge_id?: string;
|
||||||
|
nick_color?: number;
|
||||||
|
nick_effect?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PATCH /guilds/{id}/roles/{rid}` — правка роли. Клиент меняет только
|
||||||
|
* оформление: имя, права и позицию редактирует панель ролей.
|
||||||
|
*/
|
||||||
|
export async function updateRole(
|
||||||
|
guildId: string,
|
||||||
|
roleId: string,
|
||||||
|
body: RoleDecorationUpdate,
|
||||||
|
): Promise<Role> {
|
||||||
|
const payload = await request<{ role: Role }>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/roles/${encodeURIComponent(roleId)}`,
|
||||||
|
{ method: 'PATCH', body },
|
||||||
|
);
|
||||||
|
return payload.role;
|
||||||
|
}
|
||||||
|
|
||||||
export function joinGuild(guildId: string): Promise<{ ok: true }> {
|
export function joinGuild(guildId: string): Promise<{ ok: true }> {
|
||||||
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' });
|
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export interface Role {
|
|||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
hoist: boolean;
|
hoist: boolean;
|
||||||
mentionable: boolean;
|
mentionable: boolean;
|
||||||
|
/** Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника. */
|
||||||
|
cosmetic_frame_id?: string;
|
||||||
|
cosmetic_badge_id?: string;
|
||||||
|
nick_color?: number;
|
||||||
|
nick_effect?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
@@ -129,6 +134,19 @@ export interface GuildMember {
|
|||||||
is_instance_admin: boolean;
|
is_instance_admin: boolean;
|
||||||
joined_at: string;
|
joined_at: string;
|
||||||
role_ids: string[];
|
role_ids: string[];
|
||||||
|
/** Вычисленное оформление участника на сервере (AGENT.md 7.2). */
|
||||||
|
cosmetics?: MemberCosmetics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисленное оформление: сервер уже применил приоритет «роль → пер-серверный
|
||||||
|
* стиль → стиль для друзей → дефолт», клиент только рисует.
|
||||||
|
*/
|
||||||
|
export interface MemberCosmetics {
|
||||||
|
frame_id?: string;
|
||||||
|
badge_id?: string;
|
||||||
|
nick_color?: number;
|
||||||
|
nick_effect?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceInfo {
|
export interface InstanceInfo {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
|
import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
|
||||||
import { MessageItem } from '@/components/chat/MessageItem';
|
import { MessageItem } from '@/components/chat/MessageItem';
|
||||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Button, Card } from '@/components/ui/primitives';
|
import { Button, Card } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
@@ -274,25 +274,33 @@ export function MessageList({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="w-9 shrink-0 pt-0.5">
|
<div className="w-9 shrink-0 pt-0.5">
|
||||||
<Avatar
|
<FramedAvatar
|
||||||
name={author.name}
|
name={author.name}
|
||||||
seed={author.id === '' ? row.key : author.id}
|
seed={author.id === '' ? row.key : author.id}
|
||||||
fileId={author.avatarFileId}
|
fileId={author.avatarFileId}
|
||||||
url={author.avatarUrl}
|
url={author.avatarUrl}
|
||||||
size="md"
|
size="md"
|
||||||
|
cosmetics={author.cosmetics}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{showHeader ? (
|
{showHeader ? (
|
||||||
<header className="flex flex-wrap items-baseline gap-2">
|
<header className="flex flex-wrap items-baseline gap-2">
|
||||||
<span
|
{/* Ник: цвет и эффект из оформления важнее цвета роли. */}
|
||||||
className="text-sm font-semibold"
|
<Nickname
|
||||||
style={
|
name={author.name}
|
||||||
author.colorHex === null ? undefined : { color: author.colorHex }
|
cosmetics={author.cosmetics}
|
||||||
}
|
className="text-sm"
|
||||||
>
|
>
|
||||||
{author.name}
|
{author.colorHex === null ? null : (
|
||||||
</span>
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="role-color-dot"
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: author.colorHex }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Nickname>
|
||||||
{headType === 'webhook' ? (
|
{headType === 'webhook' ? (
|
||||||
<span
|
<span
|
||||||
data-testid="webhook-badge"
|
data-testid="webhook-badge"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { fetchMembers, fetchRoles, guildMembersQueryKey, guildRolesQueryKey } from '@/api/guilds';
|
import { fetchMembers, fetchRoles, guildMembersQueryKey, guildRolesQueryKey } from '@/api/guilds';
|
||||||
import { fetchUser, userQueryKey } from '@/api/users';
|
import { fetchUser, userQueryKey } from '@/api/users';
|
||||||
import type { GuildMember } from '@/api/types';
|
import type { GuildMember, MemberCosmetics } from '@/api/types';
|
||||||
import { memberDisplayName, roleColorForMember } from '@/lib/chat';
|
import { memberDisplayName, roleColorForMember } from '@/lib/chat';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ export interface ChatAuthor {
|
|||||||
colorHex: string | null;
|
colorHex: string | null;
|
||||||
isInstanceAdmin: boolean;
|
isInstanceAdmin: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
|
/** Вычисленное оформление участника на этом сервере (AGENT.md 7.2). */
|
||||||
|
cosmetics?: MemberCosmetics | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthorDirectory {
|
export interface AuthorDirectory {
|
||||||
@@ -159,6 +161,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
colorHex: roleColorForMember(member, rolesData ?? []),
|
colorHex: roleColorForMember(member, rolesData ?? []),
|
||||||
isInstanceAdmin: member.is_instance_admin,
|
isInstanceAdmin: member.is_instance_admin,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
|
...(member.cosmetics === undefined ? {} : { cosmetics: member.cosmetics }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const fallbackName = userNameMap.get(userId);
|
const fallbackName = userNameMap.get(userId);
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { fileContentUrl } from '@/api/files';
|
||||||
|
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
|
||||||
|
import type { MemberCosmetics } from '@/api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оформление профиля (AGENT.md 7.2, 7.4): рамка вокруг аватара, иконка рядом с
|
||||||
|
* ником, цвет и встроенный эффект ника. Компоненты общие для ленты, списка
|
||||||
|
* участников, друзей и профиля — сервер уже применил приоритет оформления.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FramedAvatarProps {
|
||||||
|
name: string;
|
||||||
|
seed: string;
|
||||||
|
fileId?: string | undefined;
|
||||||
|
preview?: string | undefined;
|
||||||
|
url?: string | undefined;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
shape?: 'circle' | 'square';
|
||||||
|
/** Вычисленное оформление: рамка рисуется подложкой под аватаром. */
|
||||||
|
cosmetics?: MemberCosmetics | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Аватар с рамкой: рамка — картинка под аватаром, ник и аватар поверх. */
|
||||||
|
export function FramedAvatar({
|
||||||
|
name,
|
||||||
|
seed,
|
||||||
|
fileId,
|
||||||
|
preview,
|
||||||
|
url,
|
||||||
|
size = 'md',
|
||||||
|
shape = 'circle',
|
||||||
|
cosmetics,
|
||||||
|
}: FramedAvatarProps) {
|
||||||
|
const frameId = cosmetics?.frame_id;
|
||||||
|
if (frameId === undefined || frameId === '') {
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
name={name}
|
||||||
|
seed={seed}
|
||||||
|
fileId={fileId}
|
||||||
|
preview={preview}
|
||||||
|
url={url}
|
||||||
|
size={size}
|
||||||
|
shape={shape}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Рамка крупнее аватара: у неё есть поля по краям.
|
||||||
|
const scale = { sm: 'h-9 w-9', md: 'h-11 w-11', lg: 'h-16 w-16', xl: 'h-full w-full' }[size];
|
||||||
|
const inner = { sm: 'h-7 w-7', md: 'h-9 w-9', lg: 'h-14 w-14', xl: 'h-[86%] w-[86%]' }[size];
|
||||||
|
return (
|
||||||
|
<span className={`gl-frame ${scale}`} data-testid="avatar-frame">
|
||||||
|
<img src={fileContentUrl(frameId)} alt="" loading="lazy" className="gl-frame-image" />
|
||||||
|
<span className={`gl-frame-inner ${inner}`}>
|
||||||
|
<Avatar
|
||||||
|
name={name}
|
||||||
|
seed={seed}
|
||||||
|
fileId={fileId}
|
||||||
|
preview={preview}
|
||||||
|
url={url}
|
||||||
|
size={size}
|
||||||
|
shape={shape}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка рядом с ником: маленькая картинка из галереи сервера. */
|
||||||
|
export function NickBadge({ badgeId }: { badgeId: string | undefined }) {
|
||||||
|
if (badgeId === undefined || badgeId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={fileContentUrl(badgeId)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
data-testid="nick-badge"
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NicknameProps {
|
||||||
|
name: string;
|
||||||
|
cosmetics?: MemberCosmetics | undefined;
|
||||||
|
className?: string;
|
||||||
|
/** Дополнительный значок (например системный бейдж администратора). */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ник с цветом, эффектом и иконкой оформления. */
|
||||||
|
export function Nickname({ name, cosmetics, className = '', children }: NicknameProps) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex min-w-0 items-center gap-1 ${className}`}>
|
||||||
|
<span
|
||||||
|
className={`${nickEffectClass(cosmetics?.nick_effect)} truncate`}
|
||||||
|
style={nickColorStyle(cosmetics?.nick_color)}
|
||||||
|
data-testid="nick-name"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<NickBadge badgeId={cosmetics?.badge_id} />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { roleColorHex } from '@/lib/format';
|
||||||
|
|
||||||
|
/** Палитра цвета ника: совпадает с серверной (AGENT.md 7.4). */
|
||||||
|
export const NICK_COLOR_PALETTE = [
|
||||||
|
0xf2f3f5, 0xa3a6ad, 0xe74c3c, 0xe67e22, 0xf1c40f, 0x2ecc71, 0x1abc9c, 0x3498db, 0x5865f2,
|
||||||
|
0x9b59b6, 0xe91e63, 0xff7ab8,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оформление профиля (AGENT.md 7.2, 7.4): встроенные эффекты ника и цвет из
|
||||||
|
* палитры. Вынесено из компонентов, чтобы файл экспортировал только их.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Класс встроенного эффекта ника; неизвестный эффект ничего не добавляет. */
|
||||||
|
export function nickEffectClass(effect: string | undefined): string {
|
||||||
|
switch (effect) {
|
||||||
|
case 'gradient':
|
||||||
|
case 'glow':
|
||||||
|
case 'fire':
|
||||||
|
case 'shimmer':
|
||||||
|
case 'glitch':
|
||||||
|
return `gl-nick gl-nick-${effect}`;
|
||||||
|
default:
|
||||||
|
return 'gl-nick';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Цвет ника: палитра сервера в виде hex-строки. */
|
||||||
|
export function nickColorStyle(color: number | undefined): { color: string } | undefined {
|
||||||
|
if (color === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Цвет ника — из палитры; 0 означает «как у роли».
|
||||||
|
const hex = roleColorHex(color);
|
||||||
|
return hex === null ? undefined : { color: hex };
|
||||||
|
}
|
||||||
@@ -676,6 +676,31 @@
|
|||||||
"hint": "Profile badges and frames.",
|
"hint": "Profile badges and frames.",
|
||||||
"empty": "No decorations yet.",
|
"empty": "No decorations yet.",
|
||||||
"stub": "Badges, frames and profile effects are coming later."
|
"stub": "Badges, frames and profile effects are coming later."
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"title": "Profile decoration",
|
||||||
|
"description": "Frame, icon, nick colour and effect. You can only pick what your roles grant: on a server the role wins, then the per-server style, then the friends style.",
|
||||||
|
"scope": "Where to apply",
|
||||||
|
"scopeGlobal": "For friends (DMs and global places)",
|
||||||
|
"preview": "preview",
|
||||||
|
"frame": "Avatar frame",
|
||||||
|
"badge": "Icon next to the nick",
|
||||||
|
"color": "Nick colour",
|
||||||
|
"colorDefault": "Same as role",
|
||||||
|
"effect": "Nick effect",
|
||||||
|
"none": "No element",
|
||||||
|
"noFrames": "No frames granted by roles yet — a server admin grants them.",
|
||||||
|
"noBadges": "No icons granted by roles yet.",
|
||||||
|
"reset": "Reset style",
|
||||||
|
"priorityHint": "Changes apply immediately.",
|
||||||
|
"effects": {
|
||||||
|
"none": "No effect",
|
||||||
|
"gradient": "Gradient",
|
||||||
|
"glow": "Glow",
|
||||||
|
"fire": "Fire",
|
||||||
|
"shimmer": "Shimmer",
|
||||||
|
"glitch": "Glitch"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
@@ -842,7 +867,12 @@
|
|||||||
"description": "Role colour and position. Role management arrives in the next phases.",
|
"description": "Role colour and position. Role management arrives in the next phases.",
|
||||||
"empty": "This server has no roles yet.",
|
"empty": "This server has no roles yet.",
|
||||||
"position": "Position {{position}}",
|
"position": "Position {{position}}",
|
||||||
"default": "default"
|
"default": "default",
|
||||||
|
"frame": "Frame",
|
||||||
|
"badge": "Icon",
|
||||||
|
"nickColor": "Nick colour",
|
||||||
|
"nickEffect": "Nick effect",
|
||||||
|
"decorationSaved": "Role decoration saved."
|
||||||
},
|
},
|
||||||
"danger": {
|
"danger": {
|
||||||
"title": "Danger zone",
|
"title": "Danger zone",
|
||||||
@@ -986,6 +1016,21 @@
|
|||||||
"empty": "none",
|
"empty": "none",
|
||||||
"notImage": "An image file is required (PNG, JPEG, GIF, WebP).",
|
"notImage": "An image file is required (PNG, JPEG, GIF, WebP).",
|
||||||
"tooLarge": "The file is larger than 8 MB."
|
"tooLarge": "The file is larger than 8 MB."
|
||||||
|
},
|
||||||
|
"cosmetics": {
|
||||||
|
"title": "Decoration gallery",
|
||||||
|
"description": "Avatar frames and icons next to the nick. Uploaded items are granted to roles in the Roles section; animated GIF and WebP keep their animation.",
|
||||||
|
"upload": "Upload",
|
||||||
|
"empty": "Nothing uploaded yet.",
|
||||||
|
"limit": "{{used}} of {{total}} used",
|
||||||
|
"name": "Item name",
|
||||||
|
"notImage": "An image file is required",
|
||||||
|
"tooLarge": "File is larger than 5 MB",
|
||||||
|
"deleteConfirm": "Delete the item? It disappears from all roles and personal styles.",
|
||||||
|
"kind": {
|
||||||
|
"frame": "Frames",
|
||||||
|
"badge": "Icons"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navLabel": "Settings sections",
|
"navLabel": "Settings sections",
|
||||||
|
|||||||
@@ -676,6 +676,31 @@
|
|||||||
"hint": "Бейджи и рамки профиля.",
|
"hint": "Бейджи и рамки профиля.",
|
||||||
"empty": "Украшений пока нет.",
|
"empty": "Украшений пока нет.",
|
||||||
"stub": "Бейджи, рамки и эффекты профиля появятся позже."
|
"stub": "Бейджи, рамки и эффекты профиля появятся позже."
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"title": "Оформление профиля",
|
||||||
|
"description": "Рамка, иконка, цвет и эффект ника. Выбирать можно только то, что выдали роли: на сервере приоритет у роли, затем у пер-серверного стиля, затем у стиля «для друзей».",
|
||||||
|
"scope": "Где применять",
|
||||||
|
"scopeGlobal": "Для друзей (DM и глобальные места)",
|
||||||
|
"preview": "предпросмотр",
|
||||||
|
"frame": "Рамка аватара",
|
||||||
|
"badge": "Иконка рядом с ником",
|
||||||
|
"color": "Цвет ника",
|
||||||
|
"colorDefault": "Как у роли",
|
||||||
|
"effect": "Эффект ника",
|
||||||
|
"none": "Без элемента",
|
||||||
|
"noFrames": "Роли пока не выдали ни одной рамки — их выдаёт администратор сервера.",
|
||||||
|
"noBadges": "Роли пока не выдали ни одной иконки.",
|
||||||
|
"reset": "Сбросить стиль",
|
||||||
|
"priorityHint": "Изменения применяются сразу.",
|
||||||
|
"effects": {
|
||||||
|
"none": "Без эффекта",
|
||||||
|
"gradient": "Градиент",
|
||||||
|
"glow": "Свечение",
|
||||||
|
"fire": "Огонь",
|
||||||
|
"shimmer": "Блик",
|
||||||
|
"glitch": "Глитч"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
@@ -842,7 +867,12 @@
|
|||||||
"description": "Цвет и позиция ролей. Управление ролями появится в следующих фазах.",
|
"description": "Цвет и позиция ролей. Управление ролями появится в следующих фазах.",
|
||||||
"empty": "На сервере пока нет ролей.",
|
"empty": "На сервере пока нет ролей.",
|
||||||
"position": "Позиция {{position}}",
|
"position": "Позиция {{position}}",
|
||||||
"default": "по умолчанию"
|
"default": "по умолчанию",
|
||||||
|
"frame": "Рамка",
|
||||||
|
"badge": "Иконка",
|
||||||
|
"nickColor": "Цвет ника",
|
||||||
|
"nickEffect": "Эффект ника",
|
||||||
|
"decorationSaved": "Оформление роли сохранено."
|
||||||
},
|
},
|
||||||
"danger": {
|
"danger": {
|
||||||
"title": "Опасная зона",
|
"title": "Опасная зона",
|
||||||
@@ -986,6 +1016,21 @@
|
|||||||
"empty": "нет",
|
"empty": "нет",
|
||||||
"notImage": "Нужен файл изображения (PNG, JPEG, GIF, WebP).",
|
"notImage": "Нужен файл изображения (PNG, JPEG, GIF, WebP).",
|
||||||
"tooLarge": "Файл больше 8 МБ."
|
"tooLarge": "Файл больше 8 МБ."
|
||||||
|
},
|
||||||
|
"cosmetics": {
|
||||||
|
"title": "Галерея оформления",
|
||||||
|
"description": "Рамки аватара и иконки рядом с ником. Загруженные элементы выдаются ролям в разделе «Роли»; анимированные GIF и WebP сохраняют анимацию.",
|
||||||
|
"upload": "Загрузить",
|
||||||
|
"empty": "Пока ничего не загружено.",
|
||||||
|
"limit": "занято {{used}} из {{total}}",
|
||||||
|
"name": "Имя элемента",
|
||||||
|
"notImage": "Нужен файл изображения",
|
||||||
|
"tooLarge": "Файл больше 5 МБ",
|
||||||
|
"deleteConfirm": "Удалить элемент? Он исчезнет у всех ролей и в личных стилях.",
|
||||||
|
"kind": {
|
||||||
|
"frame": "Рамки",
|
||||||
|
"badge": "Иконки"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navLabel": "Пункты настроек",
|
"navLabel": "Пункты настроек",
|
||||||
|
|||||||
@@ -422,3 +422,124 @@ body {
|
|||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Эффекты ника (AGENT.md 7.4): встроенный набор CSS-анимаций. При
|
||||||
|
* `prefers-reduced-motion: reduce` анимации гасятся общим правилом выше,
|
||||||
|
* поэтому эффект остаётся статичным градиентом или свечением.
|
||||||
|
*/
|
||||||
|
.gl-nick {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-nick-gradient {
|
||||||
|
background-image: linear-gradient(90deg, #5865f2, #eb459e, #faa61a, #5865f2);
|
||||||
|
background-size: 300% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: gl-nick-gradient 6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gl-nick-gradient {
|
||||||
|
from {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 300% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-nick-glow {
|
||||||
|
animation: gl-nick-glow 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gl-nick-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
text-shadow: 0 0 4px currentColor;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
text-shadow:
|
||||||
|
0 0 10px currentColor,
|
||||||
|
0 0 18px currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-nick-fire {
|
||||||
|
background-image: linear-gradient(180deg, #ffd166, #ff7b00, #d62828);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: gl-nick-fire 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gl-nick-fire {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-nick-shimmer {
|
||||||
|
background-image: linear-gradient(100deg, currentColor 20%, #ffffff 40%, currentColor 60%);
|
||||||
|
background-size: 250% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
animation: gl-nick-shimmer 3.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gl-nick-shimmer {
|
||||||
|
from {
|
||||||
|
background-position: 150% 50%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -50% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-nick-glitch {
|
||||||
|
animation: gl-nick-glitch 2.2s steps(2, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gl-nick-glitch {
|
||||||
|
0%,
|
||||||
|
92%,
|
||||||
|
100% {
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
93% {
|
||||||
|
text-shadow: -2px 0 #eb459e;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
text-shadow: 2px 0 #00b0f4;
|
||||||
|
}
|
||||||
|
97% {
|
||||||
|
text-shadow: -1px 0 #faa61a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Рамка вокруг аватара: подложка под аватаром, как в AGENT.md 7.4. */
|
||||||
|
.gl-frame {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-frame-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-frame-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const PERMISSION_MUTE_MEMBERS = 'MUTE_MEMBERS';
|
|||||||
export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS';
|
export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS';
|
||||||
export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS';
|
export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS';
|
||||||
export const PERMISSION_MANAGE_WEBHOOKS = 'MANAGE_WEBHOOKS';
|
export const PERMISSION_MANAGE_WEBHOOKS = 'MANAGE_WEBHOOKS';
|
||||||
|
export const PERMISSION_MANAGE_ROLES = 'MANAGE_ROLES';
|
||||||
|
|
||||||
/** Есть ли у пользователя право с указанным именем. */
|
/** Есть ли у пользователя право с указанным именем. */
|
||||||
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
||||||
@@ -191,6 +192,25 @@ export function canManageEmojis(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Может ли пользователь управлять ролями и галереей оформления (AGENT.md 7.4):
|
||||||
|
* владелец, ADMINISTRATOR, MANAGE_ROLES или администратор инстанса.
|
||||||
|
*/
|
||||||
|
export function canManageRoles(
|
||||||
|
permissions: readonly string[],
|
||||||
|
ownerId: string,
|
||||||
|
userId: string | undefined,
|
||||||
|
isInstanceAdmin = false,
|
||||||
|
): boolean {
|
||||||
|
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||||
|
hasPermission(permissions, PERMISSION_MANAGE_ROLES)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Может ли пользователь управлять вебхуками комнаты (AGENT.md 7.11):
|
* Может ли пользователь управлять вебхуками комнаты (AGENT.md 7.11):
|
||||||
* владелец, ADMINISTRATOR, MANAGE_WEBHOOKS или администратор инстанса.
|
* владелец, ADMINISTRATOR, MANAGE_WEBHOOKS или администратор инстанса.
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { fileContentUrl } from '@/api/files';
|
||||||
|
import {
|
||||||
|
COSMETICS_MAX_PER_GUILD,
|
||||||
|
COSMETIC_MAX_BYTES,
|
||||||
|
COSMETIC_NAME_MAX,
|
||||||
|
deleteGuildCosmetic,
|
||||||
|
fetchGuildCosmetics,
|
||||||
|
guildCosmeticsQueryKey,
|
||||||
|
renameGuildCosmetic,
|
||||||
|
uploadGuildCosmetic,
|
||||||
|
type CosmeticKind,
|
||||||
|
type GuildCosmetic,
|
||||||
|
} from '@/api/cosmetics';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field } from '@/components/ui/Field';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
|
interface GuildCosmeticsSectionProps {
|
||||||
|
guildId: string;
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Оформление» сервера (AGENT.md 7.4): галерея рамок и иконок. Файлы хранятся
|
||||||
|
* обычными загрузками, поэтому анимированные GIF/WebP сохраняют анимацию;
|
||||||
|
* элементы выдаются ролям в разделе «Роли».
|
||||||
|
*/
|
||||||
|
export function GuildCosmeticsSection({ guildId, canManage }: GuildCosmeticsSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const frameInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
const badgeInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [renaming, setRenaming] = useState<GuildCosmetic | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const cosmetics = useQuery({
|
||||||
|
queryKey: guildCosmeticsQueryKey(guildId),
|
||||||
|
queryFn: ({ signal }) => fetchGuildCosmetics(guildId, signal),
|
||||||
|
enabled: canManage,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = (): void => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: guildCosmeticsQueryKey(guildId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: (input: { kind: CosmeticKind; name: string; file: File }) =>
|
||||||
|
uploadGuildCosmetic(guildId, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
setError(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => setError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rename = useMutation({
|
||||||
|
mutationFn: (input: { id: string; name: string }) =>
|
||||||
|
renameGuildCosmetic(guildId, input.id, input.name),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRenaming(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => setError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteGuildCosmetic(guildId, id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: (cause: unknown) => setError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверяем файл на клиенте: сервер всё равно проверит, но так быстрее. */
|
||||||
|
const pick = (kind: CosmeticKind, file: File | undefined): void => {
|
||||||
|
if (file === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
setError(new Error(t('settings.servers.cosmetics.notImage')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > COSMETIC_MAX_BYTES) {
|
||||||
|
setError(new Error(t('settings.servers.cosmetics.tooLarge')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
upload.mutate({ kind, name: file.name.replace(/\.[^.]+$/u, ''), file });
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = cosmetics.data ?? [];
|
||||||
|
const frames = list.filter((item) => item.kind === 'frame');
|
||||||
|
const badges = list.filter((item) => item.kind === 'badge');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settings.servers.cosmetics.title')}
|
||||||
|
description={t('settings.servers.cosmetics.description')}
|
||||||
|
>
|
||||||
|
<div className="mt-4 flex flex-col gap-3" data-testid="guild-cosmetics">
|
||||||
|
{cosmetics.isPending ? <SkeletonLines rows={2} /> : null}
|
||||||
|
{cosmetics.isError ? (
|
||||||
|
<ErrorNotice error={cosmetics.error} onRetry={() => void cosmetics.refetch()} />
|
||||||
|
) : null}
|
||||||
|
{error === null ? null : <ErrorNotice error={error} />}
|
||||||
|
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t('settings.servers.cosmetics.limit', {
|
||||||
|
used: list.length,
|
||||||
|
total: COSMETICS_MAX_PER_GUILD,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{ kind: 'frame' as const, items: frames, input: frameInput },
|
||||||
|
{ kind: 'badge' as const, items: badges, input: badgeInput },
|
||||||
|
].map((group) => (
|
||||||
|
<div key={group.kind} className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t(`settings.servers.cosmetics.kind.${group.kind}`)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`cosmetic-upload-${group.kind}`}
|
||||||
|
disabled={upload.isPending || list.length >= COSMETICS_MAX_PER_GUILD}
|
||||||
|
onClick={() => group.input.current?.click()}
|
||||||
|
>
|
||||||
|
{t('settings.servers.cosmetics.upload')}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={group.input}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
aria-label={t(`settings.servers.cosmetics.kind.${group.kind}`)}
|
||||||
|
data-testid={`cosmetic-input-${group.kind}`}
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(event) => {
|
||||||
|
pick(group.kind, event.target.files?.[0]);
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{group.items.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t('settings.servers.cosmetics.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-wrap gap-2" data-testid={`cosmetic-list-${group.kind}`}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
data-testid={`cosmetic-${item.id}`}
|
||||||
|
className="flex w-40 flex-col items-center gap-1 rounded-[var(--radius-md)] border border-border/50 bg-surface-2 p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={fileContentUrl(item.file_id)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-14 w-14 object-contain"
|
||||||
|
/>
|
||||||
|
<span className="w-full truncate text-center text-xs" title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`cosmetic-rename-${item.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
setRenaming(item);
|
||||||
|
setName(item.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.channel.webhooks.rename')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border border-danger/50 text-danger"
|
||||||
|
data-testid={`cosmetic-delete-${item.id}`}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t('settings.servers.cosmetics.deleteConfirm'))) {
|
||||||
|
remove.mutate(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{renaming === null ? null : (
|
||||||
|
<form
|
||||||
|
className="flex items-end gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (name.trim() !== '') {
|
||||||
|
rename.mutate({ id: renaming.id, name: name.trim() });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('settings.servers.cosmetics.name')}
|
||||||
|
value={name}
|
||||||
|
maxLength={COSMETIC_NAME_MAX}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={rename.isPending}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setRenaming(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { GuildMember } from '@/api/types';
|
import type { GuildMember } from '@/api/types';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Badge } from '@/components/ui/primitives';
|
import { Badge } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
@@ -63,14 +63,20 @@ export function GuildMembersSection({
|
|||||||
key={member.user_id}
|
key={member.user_id}
|
||||||
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<Avatar
|
<FramedAvatar
|
||||||
name={member.display_name}
|
name={member.display_name}
|
||||||
seed={member.user_id}
|
seed={member.user_id}
|
||||||
fileId={member.avatar_file_id}
|
fileId={member.avatar_file_id}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
cosmetics={member.cosmetics}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-medium">{name}</span>
|
{/* Оформление участника приходит уже вычисленным (AGENT.md 7.2). */}
|
||||||
|
<Nickname
|
||||||
|
name={name}
|
||||||
|
cosmetics={member.cosmetics}
|
||||||
|
className="max-w-full text-sm font-medium"
|
||||||
|
/>
|
||||||
<span className="block truncate text-xs text-fg-muted">
|
<span className="block truncate text-xs text-fg-muted">
|
||||||
{member.nickname === undefined || member.nickname === ''
|
{member.nickname === undefined || member.nickname === ''
|
||||||
? `@${member.username}`
|
? `@${member.username}`
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { fetchRoles, guildRolesQueryKey } from '@/api/guilds';
|
import { fetchRoles, guildRolesQueryKey, updateRole } from '@/api/guilds';
|
||||||
|
import {
|
||||||
|
NICK_EFFECTS,
|
||||||
|
fetchGuildCosmetics,
|
||||||
|
guildCosmeticsQueryKey,
|
||||||
|
type GuildCosmetic,
|
||||||
|
type NickEffect,
|
||||||
|
} from '@/api/cosmetics';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { NICK_COLOR_PALETTE } from '@/components/profile/cosmeticsStyle';
|
||||||
import { roleColorHex } from '@/lib/format';
|
import { roleColorHex } from '@/lib/format';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
import type { Role } from '@/api/types';
|
||||||
|
|
||||||
interface GuildRolesSectionProps {
|
interface GuildRolesSectionProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
|
/** Право MANAGE_ROLES: без него оформление роли показывается только для чтения. */
|
||||||
|
canManage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** «Роли»: цвет и позиция. В Фазе 1 — только чтение. */
|
/**
|
||||||
export function GuildRolesSection({ guildId }: GuildRolesSectionProps) {
|
* «Роли»: список ролей и — при праве MANAGE_ROLES — оформление роли
|
||||||
|
* (AGENT.md 7.4): рамка, иконка, цвет и эффект ника. Рамки и иконки берутся из
|
||||||
|
* галереи сервера, поэтому она читается тем же разделом.
|
||||||
|
*/
|
||||||
|
export function GuildRolesSection({ guildId, canManage = false }: GuildRolesSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const roles = useQuery({
|
const roles = useQuery({
|
||||||
queryKey: guildRolesQueryKey(guildId),
|
queryKey: guildRolesQueryKey(guildId),
|
||||||
@@ -21,7 +37,25 @@ export function GuildRolesSection({ guildId }: GuildRolesSectionProps) {
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cosmetics = useQuery({
|
||||||
|
queryKey: guildCosmeticsQueryKey(guildId),
|
||||||
|
queryFn: ({ signal }) => fetchGuildCosmetics(guildId, signal),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: (input: { roleId: string; body: RoleDecorationInput }) =>
|
||||||
|
updateRole(guildId, input.roleId, input.body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: guildRolesQueryKey(guildId) });
|
||||||
|
// Оформление участников в ленте пересчитывается из списка участников.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['guilds', guildId, 'members'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const sorted = (roles.data ?? []).slice().sort((left, right) => right.position - left.position);
|
const sorted = (roles.data ?? []).slice().sort((left, right) => right.position - left.position);
|
||||||
|
const frames = (cosmetics.data ?? []).filter((item) => item.kind === 'frame');
|
||||||
|
const badges = (cosmetics.data ?? []).filter((item) => item.kind === 'badge');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
@@ -35,33 +69,184 @@ export function GuildRolesSection({ guildId }: GuildRolesSectionProps) {
|
|||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.roles.empty')}</p>
|
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.roles.empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-roles">
|
<ul className="gl-stagger mt-4 flex flex-col gap-2" data-testid="guild-roles">
|
||||||
{sorted.map((role) => {
|
{sorted.map((role) => {
|
||||||
const color = roleColorHex(role.color);
|
const color = roleColorHex(role.color);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={role.id}
|
key={role.id}
|
||||||
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
data-testid={`role-${role.id}`}
|
||||||
|
className="flex flex-col gap-2 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<span
|
<div className="flex items-center gap-3">
|
||||||
aria-hidden="true"
|
<span
|
||||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-border/60"
|
aria-hidden="true"
|
||||||
style={color === null ? undefined : { backgroundColor: color }}
|
className="h-3.5 w-3.5 shrink-0 rounded-full border border-border/60"
|
||||||
/>
|
style={color === null ? undefined : { backgroundColor: color }}
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{role.name}</span>
|
/>
|
||||||
{role.is_default ? (
|
<span className="min-w-0 flex-1 truncate text-sm">{role.name}</span>
|
||||||
|
{role.is_default ? (
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{t('settings.servers.roles.default')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="text-xs text-fg-muted">
|
<span className="text-xs text-fg-muted">
|
||||||
{t('settings.servers.roles.default')}
|
{t('settings.servers.roles.position', { position: role.position })}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<RoleDecoration
|
||||||
|
role={role}
|
||||||
|
frames={frames}
|
||||||
|
badges={badges}
|
||||||
|
disabled={patch.isPending}
|
||||||
|
onChange={(body) => patch.mutate({ roleId: role.id, body })}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
{t('settings.servers.roles.position', { position: role.position })}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{patch.isError ? <ErrorNotice className="mt-3" error={patch.error} /> : null}
|
||||||
|
{patch.isSuccess ? (
|
||||||
|
<p className="mt-3 text-sm text-success" role="status">
|
||||||
|
{t('settings.servers.roles.decorationSaved')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Тело запроса правки роли: только оформление. */
|
||||||
|
export interface RoleDecorationInput {
|
||||||
|
cosmetic_frame_id?: string;
|
||||||
|
cosmetic_badge_id?: string;
|
||||||
|
nick_color?: number;
|
||||||
|
nick_effect?: NickEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleDecorationProps {
|
||||||
|
role: Role;
|
||||||
|
frames: GuildCosmetic[];
|
||||||
|
badges: GuildCosmetic[];
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (body: RoleDecorationInput) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оформление роли: рамка, иконка, цвет и эффект ника. */
|
||||||
|
function RoleDecoration({ role, frames, badges, disabled, onChange }: RoleDecorationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center gap-2 pl-6"
|
||||||
|
data-testid={`role-decoration-${role.id}`}
|
||||||
|
>
|
||||||
|
<DecorationSelect
|
||||||
|
label={t('settings.servers.roles.frame')}
|
||||||
|
testId={`role-frame-${role.id}`}
|
||||||
|
value={role.cosmetic_frame_id ?? ''}
|
||||||
|
disabled={disabled}
|
||||||
|
options={frames.map((item) => ({ id: item.id, label: item.name }))}
|
||||||
|
emptyLabel={t('settings.profile.style.none')}
|
||||||
|
onChange={(value) => onChange({ cosmetic_frame_id: value })}
|
||||||
|
/>
|
||||||
|
<DecorationSelect
|
||||||
|
label={t('settings.servers.roles.badge')}
|
||||||
|
testId={`role-badge-${role.id}`}
|
||||||
|
value={role.cosmetic_badge_id ?? ''}
|
||||||
|
disabled={disabled}
|
||||||
|
options={badges.map((item) => ({ id: item.id, label: item.name }))}
|
||||||
|
emptyLabel={t('settings.profile.style.none')}
|
||||||
|
onChange={(value) => onChange({ cosmetic_badge_id: value })}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-xs">
|
||||||
|
<span className="text-fg-muted">{t('settings.servers.roles.nickColor')}</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid={`role-color-preview-${role.id}`}
|
||||||
|
className="h-4 w-4 rounded-full border border-border/60"
|
||||||
|
style={
|
||||||
|
role.nick_color === undefined
|
||||||
|
? undefined
|
||||||
|
: { backgroundColor: roleColorHex(role.nick_color) ?? undefined }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={role.nick_color === undefined ? '' : String(role.nick_color)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('settings.servers.roles.nickColor')}
|
||||||
|
data-testid={`role-color-${role.id}`}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-1 py-0.5 text-fg"
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange(
|
||||||
|
event.target.value === ''
|
||||||
|
? { nick_color: 0xffffff }
|
||||||
|
: { nick_color: Number(event.target.value) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t('settings.profile.style.colorDefault')}</option>
|
||||||
|
{NICK_COLOR_PALETTE.map((color) => (
|
||||||
|
<option key={color} value={String(color)}>
|
||||||
|
{roleColorHex(color) ?? ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<DecorationSelect
|
||||||
|
label={t('settings.servers.roles.nickEffect')}
|
||||||
|
testId={`role-effect-${role.id}`}
|
||||||
|
value={role.nick_effect ?? 'none'}
|
||||||
|
disabled={disabled}
|
||||||
|
options={NICK_EFFECTS.map((effect) => ({
|
||||||
|
id: effect,
|
||||||
|
label: t(`settings.profile.style.effects.${effect}`),
|
||||||
|
}))}
|
||||||
|
onChange={(value) => onChange({ nick_effect: value as NickEffect })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecorationSelectProps {
|
||||||
|
label: string;
|
||||||
|
testId: string;
|
||||||
|
value: string;
|
||||||
|
disabled: boolean;
|
||||||
|
options: { id: string; label: string }[];
|
||||||
|
emptyLabel?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecorationSelect({
|
||||||
|
label,
|
||||||
|
testId,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
options,
|
||||||
|
emptyLabel,
|
||||||
|
onChange,
|
||||||
|
}: DecorationSelectProps) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-1 text-xs">
|
||||||
|
<span className="text-fg-muted">{label}</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
data-testid={testId}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-1 py-0.5 text-fg"
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
>
|
||||||
|
{emptyLabel === undefined ? null : <option value="">{emptyLabel}</option>}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
canAdministerGuild,
|
canAdministerGuild,
|
||||||
canBanMembers,
|
canBanMembers,
|
||||||
canManageEmojis,
|
canManageEmojis,
|
||||||
|
canManageRoles,
|
||||||
canManageSounds,
|
canManageSounds,
|
||||||
canViewAuditLog,
|
canViewAuditLog,
|
||||||
} from '@/lib/identity';
|
} from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { GuildAppearanceSection } from '@/pages/settings/GuildAppearanceSection';
|
import { GuildAppearanceSection } from '@/pages/settings/GuildAppearanceSection';
|
||||||
|
import { GuildCosmeticsSection } from '@/pages/settings/GuildCosmeticsSection';
|
||||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||||
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
|
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
|
||||||
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||||
@@ -180,6 +182,14 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
|||||||
currentUser.data?.is_instance_admin === true,
|
currentUser.data?.is_instance_admin === true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Галерея оформления и рамки/иконки ролей (AGENT.md 7.4) — MANAGE_ROLES.
|
||||||
|
const canManageRoleSet = canManageRoles(
|
||||||
|
guild.my_permissions,
|
||||||
|
guild.owner_id,
|
||||||
|
currentUser.data?.id,
|
||||||
|
currentUser.data?.is_instance_admin === true,
|
||||||
|
);
|
||||||
|
|
||||||
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
|
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
|
||||||
const canManageSoundSet = canManageSounds(
|
const canManageSoundSet = canManageSounds(
|
||||||
guild.my_permissions,
|
guild.my_permissions,
|
||||||
@@ -203,7 +213,16 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
|||||||
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
|
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
|
||||||
currentUserId={currentUser.data?.id}
|
currentUserId={currentUser.data?.id}
|
||||||
/>
|
/>
|
||||||
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
|
<GuildCosmeticsSection
|
||||||
|
key={`${guild.id}-cosmetics`}
|
||||||
|
guildId={guild.id}
|
||||||
|
canManage={canManageRoleSet}
|
||||||
|
/>
|
||||||
|
<GuildRolesSection
|
||||||
|
key={`${guild.id}-roles`}
|
||||||
|
guildId={guild.id}
|
||||||
|
canManage={canManageRoleSet}
|
||||||
|
/>
|
||||||
{canManageEmojiSet ? (
|
{canManageEmojiSet ? (
|
||||||
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
|
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { formatBytes } from '@/lib/format';
|
|||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { ProfileStyleSection } from '@/pages/settings/ProfileStyleSection';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
/** Показываем выбранный файл до ответа сервера. */
|
/** Показываем выбранный файл до ответа сервера. */
|
||||||
@@ -302,8 +303,9 @@ export function ProfileForm({ user }: { user: User }) {
|
|||||||
user.badges.map((badge) => <Badge key={badge}>{badge}</Badge>)
|
user.badges.map((badge) => <Badge key={badge}>{badge}</Badge>)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-fg-muted">{t('settings.profile.decorations.stub')}</p>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<ProfileStyleSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { fileContentUrl } from '@/api/files';
|
||||||
|
import {
|
||||||
|
deleteMyStyle,
|
||||||
|
fetchMyStyles,
|
||||||
|
myStylesQueryKey,
|
||||||
|
putMyStyle,
|
||||||
|
type NickEffect,
|
||||||
|
type StyleInput,
|
||||||
|
} from '@/api/cosmetics';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { roleColorHex } from '@/lib/format';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Оформление профиля» (AGENT.md 7.2): стиль «для друзей» и пер-серверные
|
||||||
|
* переопределения. Выбирать можно только то, что выдали роли — списки
|
||||||
|
* доступных элементов приходят вместе со стилями.
|
||||||
|
*/
|
||||||
|
export function ProfileStyleSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const [scope, setScope] = useState('global');
|
||||||
|
|
||||||
|
const styles = useQuery({
|
||||||
|
queryKey: myStylesQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchMyStyles(signal),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = useMemo(
|
||||||
|
() => styles.data?.find((item) => item.scope === scope) ?? styles.data?.[0] ?? null,
|
||||||
|
[styles.data, scope],
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (input: StyleInput) => putMyStyle(active?.scope ?? scope, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: myStylesQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: () => deleteMyStyle(active?.scope ?? scope),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: myStylesQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = save.isPending || reset.isPending;
|
||||||
|
const style = active?.style ?? {};
|
||||||
|
|
||||||
|
/** Меняем один элемент, остальные переносим из текущего стиля. */
|
||||||
|
const apply = (patch: StyleInput): void => {
|
||||||
|
const next: StyleInput = {
|
||||||
|
nick_effect: (style.nick_effect as NickEffect | undefined) ?? 'none',
|
||||||
|
};
|
||||||
|
// Пустая строка означает «снять элемент»: сервер сбрасывает его.
|
||||||
|
if (style.frame_id !== undefined && style.frame_id !== '') {
|
||||||
|
next.frame_id = style.frame_id;
|
||||||
|
}
|
||||||
|
if (style.badge_id !== undefined && style.badge_id !== '') {
|
||||||
|
next.badge_id = style.badge_id;
|
||||||
|
}
|
||||||
|
if (style.nick_color !== undefined) {
|
||||||
|
next.nick_color = style.nick_color;
|
||||||
|
}
|
||||||
|
save.mutate({ ...next, ...patch });
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewName = currentUser.data?.display_name ?? currentUser.data?.username ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settings.profile.style.title')}
|
||||||
|
description={t('settings.profile.style.description')}
|
||||||
|
>
|
||||||
|
<div className="mt-4 flex flex-col gap-4" data-testid="profile-style">
|
||||||
|
{styles.isPending ? <SkeletonLines rows={3} /> : null}
|
||||||
|
{styles.isError ? (
|
||||||
|
<ErrorNotice error={styles.error} onRetry={() => void styles.refetch()} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{active === null ? null : (
|
||||||
|
<>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium">{t('settings.profile.style.scope')}</span>
|
||||||
|
<select
|
||||||
|
value={active.scope}
|
||||||
|
data-testid="style-scope"
|
||||||
|
onChange={(event) => setScope(event.target.value)}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
|
||||||
|
>
|
||||||
|
{(styles.data ?? []).map((item) => (
|
||||||
|
<option key={item.scope} value={item.scope}>
|
||||||
|
{item.scope === 'global'
|
||||||
|
? t('settings.profile.style.scopeGlobal')
|
||||||
|
: (item.guild_name ?? item.scope)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Предпросмотр: так оформление выглядит в ленте и списках. */}
|
||||||
|
<div className="flex items-center gap-3 rounded-[var(--radius-md)] border border-border/50 bg-surface-2 p-3">
|
||||||
|
<FramedAvatar
|
||||||
|
name={previewName}
|
||||||
|
seed={currentUser.data?.id ?? 'me'}
|
||||||
|
fileId={currentUser.data?.avatar_file_id}
|
||||||
|
size="md"
|
||||||
|
cosmetics={style}
|
||||||
|
/>
|
||||||
|
<Nickname name={previewName} cosmetics={style} className="text-sm" />
|
||||||
|
<span className="ml-auto text-xs text-fg-muted">
|
||||||
|
{t('settings.profile.style.preview')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StylePicker
|
||||||
|
label={t('settings.profile.style.frame')}
|
||||||
|
empty={active.frame_ids.length === 0}
|
||||||
|
emptyHint={t('settings.profile.style.noFrames')}
|
||||||
|
options={active.frame_ids.map((id) => ({ id, url: fileContentUrl(id) }))}
|
||||||
|
selected={style.frame_id}
|
||||||
|
disabled={pending}
|
||||||
|
onPick={(id) => apply({ frame_id: id ?? '' })}
|
||||||
|
testIdPrefix="style-frame"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StylePicker
|
||||||
|
label={t('settings.profile.style.badge')}
|
||||||
|
empty={active.badge_ids.length === 0}
|
||||||
|
emptyHint={t('settings.profile.style.noBadges')}
|
||||||
|
options={active.badge_ids.map((id) => ({ id, url: fileContentUrl(id) }))}
|
||||||
|
selected={style.badge_id}
|
||||||
|
disabled={pending}
|
||||||
|
onPick={(id) => apply({ badge_id: id ?? '' })}
|
||||||
|
testIdPrefix="style-badge"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium">{t('settings.profile.style.color')}</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2" data-testid="style-colors">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
aria-pressed={style.nick_color === undefined}
|
||||||
|
data-testid="style-color-none"
|
||||||
|
className={`h-7 rounded-[var(--radius-sm)] border px-2 text-xs transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
style.nick_color === undefined
|
||||||
|
? 'border-accent text-fg'
|
||||||
|
: 'border-border/60 text-fg-muted hover:text-fg'
|
||||||
|
}`}
|
||||||
|
onClick={() => apply({ nick_color: 0xffffff })}
|
||||||
|
>
|
||||||
|
{t('settings.profile.style.colorDefault')}
|
||||||
|
</button>
|
||||||
|
{active.nick_colors.map((color) => {
|
||||||
|
const hex = roleColorHex(color) ?? '#ffffff';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
aria-label={hex}
|
||||||
|
aria-pressed={style.nick_color === color}
|
||||||
|
data-testid={`style-color-${hex.slice(1)}`}
|
||||||
|
className={`h-7 w-7 rounded-full border-2 transition-transform duration-[var(--duration-fast)] hover:scale-110 ${
|
||||||
|
style.nick_color === color ? 'border-accent' : 'border-transparent'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: hex }}
|
||||||
|
onClick={() => apply({ nick_color: color })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium">{t('settings.profile.style.effect')}</span>
|
||||||
|
<select
|
||||||
|
value={style.nick_effect ?? 'none'}
|
||||||
|
data-testid="style-effect"
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => apply({ nick_effect: event.target.value as NickEffect })}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
|
||||||
|
>
|
||||||
|
{active.effects.map((effect) => (
|
||||||
|
<option key={effect} value={effect}>
|
||||||
|
{t(`settings.profile.style.effects.${effect}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
||||||
|
{reset.isError ? <ErrorNotice error={reset.error} /> : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="style-reset"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => reset.mutate()}
|
||||||
|
>
|
||||||
|
{t('settings.profile.style.reset')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{t('settings.profile.style.priorityHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StylePickerProps {
|
||||||
|
label: string;
|
||||||
|
empty: boolean;
|
||||||
|
emptyHint: string;
|
||||||
|
options: { id: string; url: string }[];
|
||||||
|
selected: string | undefined;
|
||||||
|
disabled: boolean;
|
||||||
|
onPick: (id: string | null) => void;
|
||||||
|
testIdPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сетка выбора элементов оформления: картинка или «без элемента». */
|
||||||
|
function StylePicker({
|
||||||
|
label,
|
||||||
|
empty,
|
||||||
|
emptyHint,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
disabled,
|
||||||
|
onPick,
|
||||||
|
testIdPrefix,
|
||||||
|
}: StylePickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
{empty && options.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{emptyHint}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2" data-testid={testIdPrefix}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={selected === undefined || selected === ''}
|
||||||
|
data-testid={`${testIdPrefix}-none`}
|
||||||
|
className={`h-12 rounded-[var(--radius-sm)] border px-2 text-xs transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
selected === undefined || selected === ''
|
||||||
|
? 'border-accent text-fg'
|
||||||
|
: 'border-border/60 text-fg-muted hover:text-fg'
|
||||||
|
}`}
|
||||||
|
onClick={() => onPick(null)}
|
||||||
|
>
|
||||||
|
{t('settings.profile.style.none')}
|
||||||
|
</button>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={selected === option.id}
|
||||||
|
data-testid={`${testIdPrefix}-${option.id}`}
|
||||||
|
className={`h-12 w-12 rounded-[var(--radius-sm)] border-2 p-1 transition-transform duration-[var(--duration-fast)] hover:scale-105 ${
|
||||||
|
selected === option.id ? 'border-accent' : 'border-transparent'
|
||||||
|
}`}
|
||||||
|
onClick={() => onPick(option.id)}
|
||||||
|
>
|
||||||
|
<img src={option.url} alt="" className="h-full w-full object-contain" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
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 { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
|
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
|
||||||
|
import {
|
||||||
|
bodyOf,
|
||||||
|
findRequest,
|
||||||
|
installFetch,
|
||||||
|
json,
|
||||||
|
makeUser,
|
||||||
|
recordedRequests,
|
||||||
|
renderApp,
|
||||||
|
resetStores,
|
||||||
|
type FetchRoute,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оформление профиля (AGENT.md 7.2, 7.4): отрисовка рамки, иконки, цвета и
|
||||||
|
* эффекта ника, редактор личного стиля и галерея оформления сервера.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const user = makeUser({ display_name: 'Аля' });
|
||||||
|
|
||||||
|
describe('отрисовка оформления', () => {
|
||||||
|
it('рисует рамку, иконку, цвет и эффект ника', () => {
|
||||||
|
render(
|
||||||
|
<div>
|
||||||
|
<FramedAvatar
|
||||||
|
name="Аля"
|
||||||
|
seed="u-1"
|
||||||
|
size="md"
|
||||||
|
cosmetics={{
|
||||||
|
frame_id: 'f-1',
|
||||||
|
badge_id: 'b-1',
|
||||||
|
nick_color: 0xe74c3c,
|
||||||
|
nick_effect: 'glow',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Nickname
|
||||||
|
name="Аля"
|
||||||
|
cosmetics={{ badge_id: 'b-1', nick_color: 0xe74c3c, nick_effect: 'glow' }}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const frame = screen.getByTestId('avatar-frame');
|
||||||
|
expect(frame.querySelector('img')?.getAttribute('src')).toBe('/files/f-1');
|
||||||
|
expect(screen.getByTestId('nick-badge').getAttribute('src')).toBe('/files/b-1');
|
||||||
|
|
||||||
|
const nick = screen.getByTestId('nick-name');
|
||||||
|
expect(nick).toHaveTextContent('Аля');
|
||||||
|
expect(nick).toHaveClass('gl-nick', 'gl-nick-glow');
|
||||||
|
expect(nick).toHaveStyle({ color: '#e74c3c' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без оформления показывает обычный аватар и ник', () => {
|
||||||
|
render(<Nickname name="Боб" />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('avatar-frame')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('nick-badge')).toBeNull();
|
||||||
|
expect(screen.getByTestId('nick-name')).toHaveClass('gl-nick');
|
||||||
|
expect(screen.getByTestId('nick-name')).not.toHaveClass('gl-nick-glow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('неизвестный эффект и нулевой цвет игнорируются', () => {
|
||||||
|
expect(nickEffectClass('unknown')).toBe('gl-nick');
|
||||||
|
expect(nickEffectClass(undefined)).toBe('gl-nick');
|
||||||
|
expect(nickEffectClass('fire')).toBe('gl-nick gl-nick-fire');
|
||||||
|
expect(nickColorStyle(0)).toBeUndefined();
|
||||||
|
expect(nickColorStyle(undefined)).toBeUndefined();
|
||||||
|
expect(nickColorStyle(0x2ecc71)).toEqual({ color: '#2ecc71' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Маршруты окна настроек: профиль, стили и серверы. */
|
||||||
|
function settingsRoutes(styles: unknown[], cosmetics: unknown[] = []): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
{ match: '/api/v1/users/@me/styles', response: () => json({ styles }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/guilds',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
guilds: [
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
is_main: true,
|
||||||
|
member_count: 1,
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: ['MANAGE_ROLES'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/cosmetics', response: () => json({ cosmetics }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||||
|
{
|
||||||
|
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_ROLES', 'ADMINISTRATOR'],
|
||||||
|
roles: [],
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stylesPayload = [
|
||||||
|
{
|
||||||
|
scope: 'global',
|
||||||
|
style: { frame_id: 'f-1' },
|
||||||
|
frame_ids: ['f-1', 'f-2'],
|
||||||
|
badge_ids: ['b-1'],
|
||||||
|
nick_colors: [0xe74c3c, 0x2ecc71],
|
||||||
|
effects: ['none', 'glow', 'gradient'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'g-1',
|
||||||
|
guild_id: 'g-1',
|
||||||
|
guild_name: 'Сервер',
|
||||||
|
style: {},
|
||||||
|
frame_ids: ['f-1'],
|
||||||
|
badge_ids: [],
|
||||||
|
nick_colors: [0xe74c3c],
|
||||||
|
effects: ['none', 'glow'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('редактор личного стиля', () => {
|
||||||
|
it('меняет рамку, иконку, цвет и эффект и сбрасывает стиль', async () => {
|
||||||
|
const fetchMock = installFetch(settingsRoutes(stylesPayload));
|
||||||
|
renderApp('/app');
|
||||||
|
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Профиль' }));
|
||||||
|
const section = await screen.findByTestId('profile-style');
|
||||||
|
|
||||||
|
// Текущий стиль подставляется в предпросмотр.
|
||||||
|
expect(within(section).getByTestId('avatar-frame')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Рамка: выбор другой рамки уходит на сервер.
|
||||||
|
await userEvent.click(within(section).getByTestId('style-frame-f-2'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
bodyOf(findRequest(fetchMock, { url: '/users/@me/styles/global', method: 'PUT' })),
|
||||||
|
).toMatchObject({ frame_id: 'f-2', nick_effect: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Иконка.
|
||||||
|
await userEvent.click(within(section).getByTestId('style-badge-b-1'));
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = recordedRequests(fetchMock).filter(
|
||||||
|
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
|
||||||
|
);
|
||||||
|
expect(calls.at(-1)?.body).toMatchObject({ badge_id: 'b-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Цвет из палитры.
|
||||||
|
await userEvent.click(within(section).getByTestId('style-color-2ecc71'));
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = recordedRequests(fetchMock).filter(
|
||||||
|
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
|
||||||
|
);
|
||||||
|
expect(calls.at(-1)?.body).toMatchObject({ nick_color: 0x2ecc71 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Эффект ника.
|
||||||
|
await userEvent.selectOptions(within(section).getByTestId('style-effect'), 'glow');
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = recordedRequests(fetchMock).filter(
|
||||||
|
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
|
||||||
|
);
|
||||||
|
expect(calls.at(-1)?.body).toMatchObject({ nick_effect: 'glow' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сброс стиля.
|
||||||
|
await userEvent.click(within(section).getByTestId('style-reset'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/users/@me/styles/global', method: 'DELETE' }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('переключает область на пер-серверный стиль', async () => {
|
||||||
|
installFetch(settingsRoutes(stylesPayload));
|
||||||
|
renderApp('/app');
|
||||||
|
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Профиль' }));
|
||||||
|
const section = await screen.findByTestId('profile-style');
|
||||||
|
|
||||||
|
expect(within(section).getByText('Для друзей (DM и глобальные места)')).toBeInTheDocument();
|
||||||
|
await userEvent.selectOptions(within(section).getByTestId('style-scope'), 'g-1');
|
||||||
|
|
||||||
|
// В пер-серверном стиле доступна только одна рамка и нет иконок.
|
||||||
|
expect(within(section).getByTestId('style-frame-f-1')).toBeInTheDocument();
|
||||||
|
expect(within(section).queryByTestId('style-frame-f-2')).toBeNull();
|
||||||
|
expect(within(section).getByText(/Роли пока не выдали ни одной иконки/u)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('галерея оформления сервера', () => {
|
||||||
|
it('загружает элемент, переименовывает и удаляет его', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const cosmetic = {
|
||||||
|
id: 'c-1',
|
||||||
|
guild_id: 'g-1',
|
||||||
|
kind: 'frame',
|
||||||
|
name: 'Золотая',
|
||||||
|
file_id: 'file-1',
|
||||||
|
created_at: '2026-09-20T10:00:00Z',
|
||||||
|
};
|
||||||
|
const routes: FetchRoute[] = [
|
||||||
|
...settingsRoutes([], [cosmetic]),
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/cosmetics',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ cosmetic }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/cosmetics/c-1',
|
||||||
|
method: 'PATCH',
|
||||||
|
response: () => json({ cosmetic: { ...cosmetic, name: 'Новая' } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/cosmetics/c-1',
|
||||||
|
method: 'DELETE',
|
||||||
|
response: () => json({ ok: true }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const fetchMock = installFetch(routes);
|
||||||
|
renderApp('/app');
|
||||||
|
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Сервер' }));
|
||||||
|
const section = await screen.findByTestId('guild-cosmetics');
|
||||||
|
|
||||||
|
// Загрузка файла: multipart-запрос с видом элемента.
|
||||||
|
const file = new File(['картинка'], 'frame.png', { type: 'image/png' });
|
||||||
|
await userEvent.upload(within(section).getByTestId('cosmetic-input-frame'), file);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
recordedRequests(fetchMock).some(
|
||||||
|
(request) => request.method === 'POST' && request.url.includes('/cosmetics'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Переименование.
|
||||||
|
await userEvent.click(within(section).getByTestId('cosmetic-rename-c-1'));
|
||||||
|
const nameInput = within(section).getByLabelText('Имя элемента');
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, 'Новая');
|
||||||
|
await userEvent.click(within(section).getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
bodyOf(findRequest(fetchMock, { url: '/cosmetics/c-1', method: 'PATCH' })),
|
||||||
|
).toMatchObject({
|
||||||
|
name: 'Новая',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удаление.
|
||||||
|
await userEvent.click(within(section).getByTestId('cosmetic-delete-c-1'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findRequest(fetchMock, { url: '/cosmetics/c-1', method: 'DELETE' })).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user