From b43452aa04d12e0099519e896e2b8f50e585e0b9 Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 18:10:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(profile):=20=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D0=BD=D1=8B=D0=B5=20=D0=B1=D0=B5=D0=B9=D0=B4=D0=B6=D0=B8?= =?UTF-8?q?=20=D1=80=D1=8F=D0=B4=D0=BE=D0=BC=20=D1=81=20=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - участники сервера отдают список бейджей из badges_json (AGENT.md 7.2): список расширяемый, назначается только системой - клиент: каталог бейджей (щит администратора инстанса, корона владельца, ранний сторонник, проверенный), неизвестные показываются нейтральной меткой - бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки - тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка) --- internal/server/api_guilds.go | 6 +++ internal/server/cosmetics_test.go | 43 +++++++++++++++ internal/store/guilds.go | 13 ++++- web/src/api/types.ts | 2 + web/src/components/chat/MessageList.tsx | 12 +++-- web/src/components/chat/useAuthorDirectory.ts | 19 ++++++- web/src/components/friends/FriendRow.tsx | 16 +++--- web/src/components/profile/SystemBadges.tsx | 48 +++++++++++++++++ web/src/i18n/locales/en.json | 6 +++ web/src/i18n/locales/ru.json | 6 +++ web/src/lib/badges.ts | 52 +++++++++++++++++++ .../pages/settings/GuildMembersSection.tsx | 21 ++++++-- web/src/pages/settings/GuildSettingsPage.tsx | 1 + web/tests/autocomplete.test.tsx | 2 + web/tests/composerFormat.test.tsx | 2 + web/tests/guildEmojis.test.tsx | 2 + web/tests/messageTypes.test.tsx | 3 ++ web/tests/profileCosmetics.test.tsx | 29 +++++++++++ web/tests/systemMessages.test.tsx | 2 + 19 files changed, 266 insertions(+), 19 deletions(-) create mode 100644 web/src/components/profile/SystemBadges.tsx create mode 100644 web/src/lib/badges.ts diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 35d9b02..d9f6416 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -62,6 +62,8 @@ type memberPayload struct { JoinedAt string `json:"joined_at"` TimeoutUntil string `json:"timeout_until,omitempty"` RoleIDs []string `json:"role_ids"` + // Системные бейджи (AGENT.md 7.2): назначаются только системой. + Badges []string `json:"badges"` // Вычисленное оформление участника на этом сервере (AGENT.md 7.2). Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"` } @@ -1416,6 +1418,10 @@ func memberFromProfile(profile store.GuildMemberProfile) memberPayload { IsInstanceAdmin: profile.IsInstanceAdmin, JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339), RoleIDs: make([]string, 0, len(profile.RoleIDs)), + Badges: profile.Badges, + } + if payload.Badges == nil { + payload.Badges = []string{} } if profile.AvatarFileID != nil { payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID) diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index d8ee1fe..9ef4f3a 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -478,3 +478,46 @@ func TestChannelBackgroundUploadAndDelete(t *testing.T) { t.Fatalf("фон не снят: %s", removed.Body.String()) } } + +func TestMembersCarrySystemBadges(t *testing.T) { + srv, owner, _, guildID, _ := cosmeticsFixture(t) + me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner) + ownerID := decodeResponse[struct { + User struct { + ID string `json:"id"` + } `json:"user"` + }](t, me).User.ID + // Бейдж назначает система (AGENT.md 7.2): в тесте ставим его напрямую. + id, err := parseID("user_id", ownerID) + if err != nil { + t.Fatalf("parse user id: %v", err) + } + if err := srv.store.SetUserBadges(t.Context(), id, []string{"instance_admin"}); err != nil { + t.Fatalf("set badges: %v", err) + } + + list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", owner) + members := decodeResponse[struct { + Members []struct { + UserID string `json:"user_id"` + Badges []string `json:"badges"` + } `json:"members"` + }](t, list) + + var found bool + for _, entry := range members.Members { + if entry.Badges == nil { + t.Fatalf("бейджи должны быть списком, а не null") + } + if entry.UserID != ownerID { + continue + } + found = true + if !contains(entry.Badges, "instance_admin") { + t.Fatalf("бейджи владельца = %v, ожидался instance_admin", entry.Badges) + } + } + if !found { + t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String()) + } +} diff --git a/internal/store/guilds.go b/internal/store/guilds.go index cc245c0..c1d4943 100644 --- a/internal/store/guilds.go +++ b/internal/store/guilds.go @@ -384,13 +384,17 @@ type GuildMemberProfile struct { JoinedAt time.Time TimeoutUntil *time.Time RoleIDs []uint64 + // Badges — системные бейджи пользователя (AGENT.md 7.2): список + // расширяемый, назначается только системой. + Badges []string } // ListGuildMemberProfiles возвращает участников сервера с профилями и ролями. func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) { rows, err := s.reader.QueryContext(ctx, ` SELECT m.user_id, u.username, u.display_name, m.nickname, u.avatar_file_id, - u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until + u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until, + u.badges_json FROM guild_members m JOIN users u ON u.id = m.user_id WHERE m.guild_id = ? AND u.deleted_at IS NULL @@ -409,11 +413,16 @@ func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([] isAdmin int joinedAt string timeoutUntil sql.NullString + badges string ) if err := rows.Scan(&profile.UserID, &profile.Username, &profile.DisplayName, &nickname, - &avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil); err != nil { + &avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil, + &badges); err != nil { return nil, err } + if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil { + profile.Badges = nil + } if nickname.Valid { profile.Nickname = nickname.String } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 902f90a..f3a229e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -143,6 +143,8 @@ export interface GuildMember { is_instance_admin: boolean; joined_at: string; role_ids: string[]; + /** Системные бейджи участника (AGENT.md 7.2). */ + badges?: string[]; /** Вычисленное оформление участника на сервере (AGENT.md 7.2). */ cosmetics?: MemberCosmetics; } diff --git a/web/src/components/chat/MessageList.tsx b/web/src/components/chat/MessageList.tsx index 2e71786..31bddf4 100644 --- a/web/src/components/chat/MessageList.tsx +++ b/web/src/components/chat/MessageList.tsx @@ -5,6 +5,7 @@ import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages'; import { MessageItem } from '@/components/chat/MessageItem'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; +import { SystemBadges } from '@/components/profile/SystemBadges'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Button, Card } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; @@ -301,6 +302,11 @@ export function MessageList({ /> )} + {headType === 'webhook' ? ( ) : null} - {author.isInstanceAdmin ? ( - - {t('chat.adminBadge')} - - ) : null} + - {/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */} - + {/* Ник с оформлением «для друзей»: цвет, эффект, иконка и бейджи. */} + + + + @{friend.username} diff --git a/web/src/components/profile/SystemBadges.tsx b/web/src/components/profile/SystemBadges.tsx new file mode 100644 index 0000000..caa9bbf --- /dev/null +++ b/web/src/components/profile/SystemBadges.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next'; + +import { badgesFor, systemBadge } from '@/lib/badges'; + +interface SystemBadgesProps { + /** Имена бейджей из `badges_json` (AGENT.md 7.2). */ + badges?: readonly string[] | undefined; + isOwner?: boolean; + isInstanceAdmin?: boolean; + className?: string; +} + +/** + * Системные бейджи рядом с ником: администратор инстанса, владелец сервера и + * другие метки из `badges_json`. Пользователь их не редактирует. + */ +export function SystemBadges({ + badges, + isOwner = false, + isInstanceAdmin = false, + className = '', +}: SystemBadgesProps) { + const { t } = useTranslation(); + const list = badgesFor(badges, isOwner, isInstanceAdmin); + if (list.length === 0) { + return null; + } + return ( + + {list.map((id) => { + const badge = systemBadge(id); + const label = badge.labelKey === '' ? id : t(badge.labelKey); + return ( + + {badge.icon} + + ); + })} + + ); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 77b692e..fbc12d9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1115,5 +1115,11 @@ "notImage": "An image file is required", "tooLarge": "File is larger than 5 MB" } + }, + "badges": { + "instanceAdmin": "Instance administrator", + "guildOwner": "Server owner", + "earlySupporter": "Early supporter", + "verified": "Verified" } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 7dab358..003d32e 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1115,5 +1115,11 @@ "notImage": "Нужен файл изображения", "tooLarge": "Файл больше 5 МБ" } + }, + "badges": { + "instanceAdmin": "Администратор инстанса", + "guildOwner": "Владелец сервера", + "earlySupporter": "Ранний сторонник", + "verified": "Проверенный" } } diff --git a/web/src/lib/badges.ts b/web/src/lib/badges.ts new file mode 100644 index 0000000..fccd53b --- /dev/null +++ b/web/src/lib/badges.ts @@ -0,0 +1,52 @@ +/** + * Системные бейджи (AGENT.md 7.2): метки рядом с ником, которые назначает + * только система. Список расширяемый (`badges_json`), поэтому неизвестные + * бейджи показываются нейтральной меткой, а не пропадают. + */ + +export interface SystemBadge { + /** Имя бейджа из `badges_json`. */ + id: string; + /** Значок: эмодзи, чтобы не тащить в бандл отдельные картинки. */ + icon: string; + /** Ключ подписи в локалях. */ + labelKey: string; +} + +/** Известные системные бейджи. */ +export const SYSTEM_BADGES: SystemBadge[] = [ + { id: 'instance_admin', icon: '🛡', labelKey: 'badges.instanceAdmin' }, + { id: 'guild_owner', icon: '👑', labelKey: 'badges.guildOwner' }, + { id: 'early_supporter', icon: '💎', labelKey: 'badges.earlySupporter' }, + { id: 'verified', icon: '✔', labelKey: 'badges.verified' }, +]; + +/** Бейдж владельца сервера: система его не хранит, он вычисляется на клиенте. */ +export const GUILD_OWNER_BADGE = 'guild_owner'; + +/** Описание бейджа: известные — из каталога, остальные — нейтральные. */ +export function systemBadge(id: string): SystemBadge { + const known = SYSTEM_BADGES.find((badge) => badge.id === id); + if (known !== undefined) { + return known; + } + return { id, icon: '•', labelKey: '' }; +} + +/** Список бейджей участника: системные плюс вычисленный бейдж владельца. */ +export function badgesFor( + badges: readonly string[] | undefined, + isOwner: boolean, + isInstanceAdmin = false, +): string[] { + const result = new Set(badges ?? []); + // Администратор инстанса всегда помечен, даже если `badges_json` пуст + // (бейдж назначается системой при выдаче прав). + if (isInstanceAdmin) { + result.add('instance_admin'); + } + if (isOwner) { + result.add(GUILD_OWNER_BADGE); + } + return [...result]; +} diff --git a/web/src/pages/settings/GuildMembersSection.tsx b/web/src/pages/settings/GuildMembersSection.tsx index 6c6421a..e5ef069 100644 --- a/web/src/pages/settings/GuildMembersSection.tsx +++ b/web/src/pages/settings/GuildMembersSection.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import type { GuildMember } from '@/api/types'; import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; +import { SystemBadges } from '@/components/profile/SystemBadges'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Badge } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; @@ -20,6 +21,8 @@ interface GuildMembersSectionProps { canTimeout?: boolean; /** Свой профиль: себя модерировать нельзя. */ currentUserId?: string | undefined; + /** Владелец сервера: рядом с ником показывается его бейдж (AGENT.md 7.2). */ + ownerId?: string | undefined; } /** Сколько участников показываем в превью (полный список — в следующих фазах). */ @@ -36,6 +39,7 @@ export function GuildMembersSection({ canBan = false, canTimeout = false, currentUserId, + ownerId, }: GuildMembersSectionProps) { const { t } = useTranslation(); const preview = (members ?? []).slice(0, PREVIEW_LIMIT); @@ -72,11 +76,18 @@ export function GuildMembersSection({ /> {/* Оформление участника приходит уже вычисленным (AGENT.md 7.2). */} - + + + + {member.nickname === undefined || member.nickname === '' ? `@${member.username}` diff --git a/web/src/pages/settings/GuildSettingsPage.tsx b/web/src/pages/settings/GuildSettingsPage.tsx index a6f0581..91ce486 100644 --- a/web/src/pages/settings/GuildSettingsPage.tsx +++ b/web/src/pages/settings/GuildSettingsPage.tsx @@ -212,6 +212,7 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) { canBan={canBan} canTimeout={canModerateMembers('TIMEOUT_MEMBERS')} currentUserId={currentUser.data?.id} + ownerId={guild.owner_id} /> ({ @@ -109,6 +110,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: false, }), resolveMention: (userId) => ({ diff --git a/web/tests/composerFormat.test.tsx b/web/tests/composerFormat.test.tsx index c3145b8..6e5332a 100644 --- a/web/tests/composerFormat.test.tsx +++ b/web/tests/composerFormat.test.tsx @@ -31,6 +31,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: true, }), resolveWebhook: () => ({ @@ -39,6 +40,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: false, }), resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }), diff --git a/web/tests/guildEmojis.test.tsx b/web/tests/guildEmojis.test.tsx index 4d175bd..bf6de8a 100644 --- a/web/tests/guildEmojis.test.tsx +++ b/web/tests/guildEmojis.test.tsx @@ -72,6 +72,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: true, }), resolveWebhook: () => ({ @@ -80,6 +81,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: false, }), resolveMention: (userId) => ({ name: userId, isMe: false }), diff --git a/web/tests/messageTypes.test.tsx b/web/tests/messageTypes.test.tsx index 311d9a5..1575b6b 100644 --- a/web/tests/messageTypes.test.tsx +++ b/web/tests/messageTypes.test.tsx @@ -19,6 +19,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: true, }), resolveWebhook: () => ({ @@ -27,6 +28,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: false, }), resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }), @@ -223,6 +225,7 @@ describe('сообщения вебхуков', () => { avatarUrl: message.webhook_avatar, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: false, }), }; diff --git a/web/tests/profileCosmetics.test.tsx b/web/tests/profileCosmetics.test.tsx index 53f1b8a..04a343c 100644 --- a/web/tests/profileCosmetics.test.tsx +++ b/web/tests/profileCosmetics.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { AnimatedImage } from '@/components/ui/AnimatedImage'; import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; +import { SystemBadges } from '@/components/profile/SystemBadges'; import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle'; import { bodyOf, @@ -498,3 +499,31 @@ describe('фон комнаты', () => { expect(image?.getAttribute('data-animated')).toBe('animated'); }); }); + +describe('системные бейджи', () => { + it('показывает щит администратора инстанса и корону владельца', () => { + render(); + + expect(screen.getByTestId('system-badge-instance_admin')).toHaveTextContent('🛡'); + expect(screen.getByTestId('system-badge-guild_owner')).toHaveTextContent('👑'); + expect(screen.getByTestId('system-badge-early_supporter')).toHaveAttribute( + 'title', + 'Ранний сторонник', + ); + // Администратор инстанса помечен даже без записи в badges_json. + expect(screen.getByLabelText('Администратор инстанса')).toBeInTheDocument(); + }); + + it('неизвестный бейдж показывается нейтральной меткой, а не пропадает', () => { + render(); + + const badge = screen.getByTestId('system-badge-future_badge'); + expect(badge).toHaveTextContent('•'); + expect(badge).toHaveAttribute('title', 'future_badge'); + }); + + it('без бейджей ничего не рисует', () => { + const { container } = render(); + expect(container.querySelector('[data-testid="system-badges"]')).toBeNull(); + }); +}); diff --git a/web/tests/systemMessages.test.tsx b/web/tests/systemMessages.test.tsx index f747e74..5f7c4f1 100644 --- a/web/tests/systemMessages.test.tsx +++ b/web/tests/systemMessages.test.tsx @@ -29,6 +29,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: userId === 'user-2', }), resolveWebhook: () => ({ @@ -37,6 +38,7 @@ const authors: AuthorDirectory = { avatarFileId: undefined, colorHex: null, isInstanceAdmin: false, + isOwner: false, isMember: false, }), resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),