feat(profile): системные бейджи рядом с ником

- участники сервера отдают список бейджей из badges_json (AGENT.md 7.2):
  список расширяемый, назначается только системой
- клиент: каталог бейджей (щит администратора инстанса, корона владельца,
  ранний сторонник, проверенный), неизвестные показываются нейтральной меткой
- бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки
- тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка)
This commit is contained in:
2026-09-21 18:10:39 +03:00
parent 0f0060e8a4
commit b43452aa04
19 changed files with 266 additions and 19 deletions
+6
View File
@@ -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)
+43
View File
@@ -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())
}
}
+11 -2
View File
@@ -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
}
+2
View File
@@ -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;
}
+7 -5
View File
@@ -5,6 +5,7 @@ import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
import { MessageItem } from '@/components/chat/MessageItem';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { SystemBadges } from '@/components/profile/SystemBadges';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
@@ -301,6 +302,11 @@ export function MessageList({
/>
)}
</Nickname>
<SystemBadges
badges={undefined}
isOwner={author.isOwner}
isInstanceAdmin={author.isInstanceAdmin}
/>
{headType === 'webhook' ? (
<span
data-testid="webhook-badge"
@@ -309,11 +315,7 @@ export function MessageList({
{t('chat.webhookBadge')}
</span>
) : null}
{author.isInstanceAdmin ? (
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
{t('chat.adminBadge')}
</span>
) : null}
<time
dateTime={first.created_at}
className="text-xs text-fg-muted"
+18 -1
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -24,6 +24,8 @@ export interface ChatAuthor {
avatarUrl?: string | undefined;
colorHex: string | null;
isInstanceAdmin: boolean;
/** Владелец сервера: бейдж вычисляется на клиенте (AGENT.md 7.2). */
isOwner: boolean;
isMember: boolean;
/** Вычисленное оформление участника на этом сервере (AGENT.md 7.2). */
cosmetics?: MemberCosmetics | undefined;
@@ -54,6 +56,16 @@ export interface AuthorDirectory {
export function useAuthorDirectory(guildId: string | null, userIds: string[]): AuthorDirectory {
const { t } = useTranslation();
const currentUserId = useSessionStore((state) => state.user?.id ?? null);
// Владелец сервера: бейдж владельца показывается рядом с ником (7.2).
const ownerId = useSessionStore(
useCallback(
(state) =>
guildId === null
? null
: (state.guilds.find((guild) => guild.id === guildId)?.owner_id ?? null),
[guildId],
),
);
const members = useQuery({
queryKey: guildMembersQueryKey(guildId ?? ''),
@@ -161,6 +173,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isOwner: false,
isMember: false,
};
}
@@ -172,6 +185,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
avatarFileId: member.avatar_file_id,
colorHex: roleColorForMember(member, rolesData ?? []),
isInstanceAdmin: member.is_instance_admin,
isOwner: ownerId !== null && userId === ownerId,
isMember: true,
...(member.cosmetics === undefined ? {} : { cosmetics: member.cosmetics }),
};
@@ -184,6 +198,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
avatarFileId: userAvatarMap.get(userId),
colorHex: null,
isInstanceAdmin: false,
isOwner: false,
isMember: false,
...(fallbackCosmetics === undefined ? {} : { cosmetics: fallbackCosmetics }),
};
@@ -203,6 +218,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
avatarUrl: message.webhook_avatar,
colorHex: null,
isInstanceAdmin: false,
isOwner: false,
isMember: false,
});
@@ -224,6 +240,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
};
}, [
memberMap,
ownerId,
rolesData,
userNameMap,
userAvatarMap,
+10 -6
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { removeRelationship, type RelationshipUser } from '@/api/friends';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { SystemBadges } from '@/components/profile/SystemBadges';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { Popover } from '@/components/ui/Popover';
@@ -93,12 +94,15 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
/>
</span>
<span className="min-w-0 flex-1">
{/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */}
<Nickname
name={displayName}
cosmetics={friend.cosmetics}
className="text-sm font-medium"
/>
{/* Ник с оформлением «для друзей»: цвет, эффект, иконка и бейджи. */}
<span className="flex items-center gap-1">
<Nickname
name={displayName}
cosmetics={friend.cosmetics}
className="text-sm font-medium"
/>
<SystemBadges badges={friend.badges} isInstanceAdmin={friend.is_instance_admin} />
</span>
<span className="block truncate text-xs text-fg-muted">@{friend.username}</span>
</span>
</button>
@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next';
import { badgesFor, systemBadge } from '@/lib/badges';
interface SystemBadgesProps {
/** Имена бейджей из `badges_json` (AGENT.md 7.2). */
badges?: readonly string[] | undefined;
isOwner?: boolean;
isInstanceAdmin?: boolean;
className?: string;
}
/**
* Системные бейджи рядом с ником: администратор инстанса, владелец сервера и
* другие метки из `badges_json`. Пользователь их не редактирует.
*/
export function SystemBadges({
badges,
isOwner = false,
isInstanceAdmin = false,
className = '',
}: SystemBadgesProps) {
const { t } = useTranslation();
const list = badgesFor(badges, isOwner, isInstanceAdmin);
if (list.length === 0) {
return null;
}
return (
<span className={`inline-flex items-center gap-0.5 ${className}`} data-testid="system-badges">
{list.map((id) => {
const badge = systemBadge(id);
const label = badge.labelKey === '' ? id : t(badge.labelKey);
return (
<span
key={id}
role="img"
aria-label={label}
title={label}
data-testid={`system-badge-${id}`}
className="cursor-help text-xs leading-none"
>
{badge.icon}
</span>
);
})}
</span>
);
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -1115,5 +1115,11 @@
"notImage": "Нужен файл изображения",
"tooLarge": "Файл больше 5 МБ"
}
},
"badges": {
"instanceAdmin": "Администратор инстанса",
"guildOwner": "Владелец сервера",
"earlySupporter": "Ранний сторонник",
"verified": "Проверенный"
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Системные бейджи (AGENT.md 7.2): метки рядом с ником, которые назначает
* только система. Список расширяемый (`badges_json`), поэтому неизвестные
* бейджи показываются нейтральной меткой, а не пропадают.
*/
export interface SystemBadge {
/** Имя бейджа из `badges_json`. */
id: string;
/** Значок: эмодзи, чтобы не тащить в бандл отдельные картинки. */
icon: string;
/** Ключ подписи в локалях. */
labelKey: string;
}
/** Известные системные бейджи. */
export const SYSTEM_BADGES: SystemBadge[] = [
{ id: 'instance_admin', icon: '🛡', labelKey: 'badges.instanceAdmin' },
{ id: 'guild_owner', icon: '👑', labelKey: 'badges.guildOwner' },
{ id: 'early_supporter', icon: '💎', labelKey: 'badges.earlySupporter' },
{ id: 'verified', icon: '✔', labelKey: 'badges.verified' },
];
/** Бейдж владельца сервера: система его не хранит, он вычисляется на клиенте. */
export const GUILD_OWNER_BADGE = 'guild_owner';
/** Описание бейджа: известные — из каталога, остальные — нейтральные. */
export function systemBadge(id: string): SystemBadge {
const known = SYSTEM_BADGES.find((badge) => badge.id === id);
if (known !== undefined) {
return known;
}
return { id, icon: '•', labelKey: '' };
}
/** Список бейджей участника: системные плюс вычисленный бейдж владельца. */
export function badgesFor(
badges: readonly string[] | undefined,
isOwner: boolean,
isInstanceAdmin = false,
): string[] {
const result = new Set<string>(badges ?? []);
// Администратор инстанса всегда помечен, даже если `badges_json` пуст
// (бейдж назначается системой при выдаче прав).
if (isInstanceAdmin) {
result.add('instance_admin');
}
if (isOwner) {
result.add(GUILD_OWNER_BADGE);
}
return [...result];
}
+16 -5
View File
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import type { GuildMember } from '@/api/types';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { SystemBadges } from '@/components/profile/SystemBadges';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Badge } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
@@ -20,6 +21,8 @@ interface GuildMembersSectionProps {
canTimeout?: boolean;
/** Свой профиль: себя модерировать нельзя. */
currentUserId?: string | undefined;
/** Владелец сервера: рядом с ником показывается его бейдж (AGENT.md 7.2). */
ownerId?: string | undefined;
}
/** Сколько участников показываем в превью (полный список — в следующих фазах). */
@@ -36,6 +39,7 @@ export function GuildMembersSection({
canBan = false,
canTimeout = false,
currentUserId,
ownerId,
}: GuildMembersSectionProps) {
const { t } = useTranslation();
const preview = (members ?? []).slice(0, PREVIEW_LIMIT);
@@ -72,11 +76,18 @@ export function GuildMembersSection({
/>
<span className="min-w-0 flex-1">
{/* Оформление участника приходит уже вычисленным (AGENT.md 7.2). */}
<Nickname
name={name}
cosmetics={member.cosmetics}
className="max-w-full text-sm font-medium"
/>
<span className="flex items-center gap-1">
<Nickname
name={name}
cosmetics={member.cosmetics}
className="max-w-full text-sm font-medium"
/>
<SystemBadges
badges={member.badges}
isInstanceAdmin={member.is_instance_admin}
isOwner={member.user_id === ownerId}
/>
</span>
<span className="block truncate text-xs text-fg-muted">
{member.nickname === undefined || member.nickname === ''
? `@${member.username}`
@@ -212,6 +212,7 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
canBan={canBan}
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
currentUserId={currentUser.data?.id}
ownerId={guild.owner_id}
/>
<GuildCosmeticsSection
key={`${guild.id}-cosmetics`}
+2
View File
@@ -101,6 +101,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isOwner: false,
isMember: true,
}),
resolveWebhook: () => ({
@@ -109,6 +110,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isOwner: false,
isMember: false,
}),
resolveMention: (userId) => ({
+2
View File
@@ -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 }),
+2
View File
@@ -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 }),
+3
View File
@@ -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,
}),
};
+29
View File
@@ -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(<SystemBadges badges={['instance_admin', 'early_supporter']} isOwner isInstanceAdmin />);
expect(screen.getByTestId('system-badge-instance_admin')).toHaveTextContent('🛡');
expect(screen.getByTestId('system-badge-guild_owner')).toHaveTextContent('👑');
expect(screen.getByTestId('system-badge-early_supporter')).toHaveAttribute(
'title',
'Ранний сторонник',
);
// Администратор инстанса помечен даже без записи в badges_json.
expect(screen.getByLabelText('Администратор инстанса')).toBeInTheDocument();
});
it('неизвестный бейдж показывается нейтральной меткой, а не пропадает', () => {
render(<SystemBadges badges={['future_badge']} />);
const badge = screen.getByTestId('system-badge-future_badge');
expect(badge).toHaveTextContent('•');
expect(badge).toHaveAttribute('title', 'future_badge');
});
it('без бейджей ничего не рисует', () => {
const { container } = render(<SystemBadges />);
expect(container.querySelector('[data-testid="system-badges"]')).toBeNull();
});
});
+2
View File
@@ -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 }),