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