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"`
|
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): назначаются только системой.
|
||||||
|
Badges []string `json:"badges"`
|
||||||
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
|
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
|
||||||
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
|
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -1416,6 +1418,10 @@ func memberFromProfile(profile store.GuildMemberProfile) memberPayload {
|
|||||||
IsInstanceAdmin: profile.IsInstanceAdmin,
|
IsInstanceAdmin: profile.IsInstanceAdmin,
|
||||||
JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339),
|
JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339),
|
||||||
RoleIDs: make([]string, 0, len(profile.RoleIDs)),
|
RoleIDs: make([]string, 0, len(profile.RoleIDs)),
|
||||||
|
Badges: profile.Badges,
|
||||||
|
}
|
||||||
|
if payload.Badges == nil {
|
||||||
|
payload.Badges = []string{}
|
||||||
}
|
}
|
||||||
if profile.AvatarFileID != nil {
|
if profile.AvatarFileID != nil {
|
||||||
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
|
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
|
||||||
|
|||||||
@@ -478,3 +478,46 @@ func TestChannelBackgroundUploadAndDelete(t *testing.T) {
|
|||||||
t.Fatalf("фон не снят: %s", removed.Body.String())
|
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
|
JoinedAt time.Time
|
||||||
TimeoutUntil *time.Time
|
TimeoutUntil *time.Time
|
||||||
RoleIDs []uint64
|
RoleIDs []uint64
|
||||||
|
// Badges — системные бейджи пользователя (AGENT.md 7.2): список
|
||||||
|
// расширяемый, назначается только системой.
|
||||||
|
Badges []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListGuildMemberProfiles возвращает участников сервера с профилями и ролями.
|
// ListGuildMemberProfiles возвращает участников сервера с профилями и ролями.
|
||||||
func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) {
|
func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) {
|
||||||
rows, err := s.reader.QueryContext(ctx, `
|
rows, err := s.reader.QueryContext(ctx, `
|
||||||
SELECT m.user_id, u.username, u.display_name, m.nickname, u.avatar_file_id,
|
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
|
FROM guild_members m
|
||||||
JOIN users u ON u.id = m.user_id
|
JOIN users u ON u.id = m.user_id
|
||||||
WHERE m.guild_id = ? AND u.deleted_at IS NULL
|
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
|
isAdmin int
|
||||||
joinedAt string
|
joinedAt string
|
||||||
timeoutUntil sql.NullString
|
timeoutUntil sql.NullString
|
||||||
|
badges string
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&profile.UserID, &profile.Username, &profile.DisplayName, &nickname,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil {
|
||||||
|
profile.Badges = nil
|
||||||
|
}
|
||||||
if nickname.Valid {
|
if nickname.Valid {
|
||||||
profile.Nickname = nickname.String
|
profile.Nickname = nickname.String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ 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). */
|
||||||
|
badges?: string[];
|
||||||
/** Вычисленное оформление участника на сервере (AGENT.md 7.2). */
|
/** Вычисленное оформление участника на сервере (AGENT.md 7.2). */
|
||||||
cosmetics?: MemberCosmetics;
|
cosmetics?: MemberCosmetics;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
|
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||||
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';
|
||||||
@@ -301,6 +302,11 @@ export function MessageList({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Nickname>
|
</Nickname>
|
||||||
|
<SystemBadges
|
||||||
|
badges={undefined}
|
||||||
|
isOwner={author.isOwner}
|
||||||
|
isInstanceAdmin={author.isInstanceAdmin}
|
||||||
|
/>
|
||||||
{headType === 'webhook' ? (
|
{headType === 'webhook' ? (
|
||||||
<span
|
<span
|
||||||
data-testid="webhook-badge"
|
data-testid="webhook-badge"
|
||||||
@@ -309,11 +315,7 @@ export function MessageList({
|
|||||||
{t('chat.webhookBadge')}
|
{t('chat.webhookBadge')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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
|
<time
|
||||||
dateTime={first.created_at}
|
dateTime={first.created_at}
|
||||||
className="text-xs text-fg-muted"
|
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 { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ export interface ChatAuthor {
|
|||||||
avatarUrl?: string | undefined;
|
avatarUrl?: string | undefined;
|
||||||
colorHex: string | null;
|
colorHex: string | null;
|
||||||
isInstanceAdmin: boolean;
|
isInstanceAdmin: boolean;
|
||||||
|
/** Владелец сервера: бейдж вычисляется на клиенте (AGENT.md 7.2). */
|
||||||
|
isOwner: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
/** Вычисленное оформление участника на этом сервере (AGENT.md 7.2). */
|
/** Вычисленное оформление участника на этом сервере (AGENT.md 7.2). */
|
||||||
cosmetics?: MemberCosmetics | undefined;
|
cosmetics?: MemberCosmetics | undefined;
|
||||||
@@ -54,6 +56,16 @@ export interface AuthorDirectory {
|
|||||||
export function useAuthorDirectory(guildId: string | null, userIds: string[]): AuthorDirectory {
|
export function useAuthorDirectory(guildId: string | null, userIds: string[]): AuthorDirectory {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentUserId = useSessionStore((state) => state.user?.id ?? null);
|
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({
|
const members = useQuery({
|
||||||
queryKey: guildMembersQueryKey(guildId ?? ''),
|
queryKey: guildMembersQueryKey(guildId ?? ''),
|
||||||
@@ -161,6 +173,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -172,6 +185,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
avatarFileId: member.avatar_file_id,
|
avatarFileId: member.avatar_file_id,
|
||||||
colorHex: roleColorForMember(member, rolesData ?? []),
|
colorHex: roleColorForMember(member, rolesData ?? []),
|
||||||
isInstanceAdmin: member.is_instance_admin,
|
isInstanceAdmin: member.is_instance_admin,
|
||||||
|
isOwner: ownerId !== null && userId === ownerId,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
...(member.cosmetics === undefined ? {} : { cosmetics: member.cosmetics }),
|
...(member.cosmetics === undefined ? {} : { cosmetics: member.cosmetics }),
|
||||||
};
|
};
|
||||||
@@ -184,6 +198,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
avatarFileId: userAvatarMap.get(userId),
|
avatarFileId: userAvatarMap.get(userId),
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
...(fallbackCosmetics === undefined ? {} : { cosmetics: fallbackCosmetics }),
|
...(fallbackCosmetics === undefined ? {} : { cosmetics: fallbackCosmetics }),
|
||||||
};
|
};
|
||||||
@@ -203,6 +218,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
avatarUrl: message.webhook_avatar,
|
avatarUrl: message.webhook_avatar,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,6 +240,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
memberMap,
|
memberMap,
|
||||||
|
ownerId,
|
||||||
rolesData,
|
rolesData,
|
||||||
userNameMap,
|
userNameMap,
|
||||||
userAvatarMap,
|
userAvatarMap,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
||||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
|
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||||
import { Popover } from '@/components/ui/Popover';
|
import { Popover } from '@/components/ui/Popover';
|
||||||
@@ -93,12 +94,15 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
{/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */}
|
{/* Ник с оформлением «для друзей»: цвет, эффект, иконка и бейджи. */}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
<Nickname
|
<Nickname
|
||||||
name={displayName}
|
name={displayName}
|
||||||
cosmetics={friend.cosmetics}
|
cosmetics={friend.cosmetics}
|
||||||
className="text-sm font-medium"
|
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 className="block truncate text-xs text-fg-muted">@{friend.username}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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",
|
"notImage": "An image file is required",
|
||||||
"tooLarge": "File is larger than 5 MB"
|
"tooLarge": "File is larger than 5 MB"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"instanceAdmin": "Instance administrator",
|
||||||
|
"guildOwner": "Server owner",
|
||||||
|
"earlySupporter": "Early supporter",
|
||||||
|
"verified": "Verified"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1115,5 +1115,11 @@
|
|||||||
"notImage": "Нужен файл изображения",
|
"notImage": "Нужен файл изображения",
|
||||||
"tooLarge": "Файл больше 5 МБ"
|
"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 type { GuildMember } from '@/api/types';
|
||||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
|
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||||
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';
|
||||||
@@ -20,6 +21,8 @@ interface GuildMembersSectionProps {
|
|||||||
canTimeout?: boolean;
|
canTimeout?: boolean;
|
||||||
/** Свой профиль: себя модерировать нельзя. */
|
/** Свой профиль: себя модерировать нельзя. */
|
||||||
currentUserId?: string | undefined;
|
currentUserId?: string | undefined;
|
||||||
|
/** Владелец сервера: рядом с ником показывается его бейдж (AGENT.md 7.2). */
|
||||||
|
ownerId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сколько участников показываем в превью (полный список — в следующих фазах). */
|
/** Сколько участников показываем в превью (полный список — в следующих фазах). */
|
||||||
@@ -36,6 +39,7 @@ export function GuildMembersSection({
|
|||||||
canBan = false,
|
canBan = false,
|
||||||
canTimeout = false,
|
canTimeout = false,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
ownerId,
|
||||||
}: GuildMembersSectionProps) {
|
}: GuildMembersSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const preview = (members ?? []).slice(0, PREVIEW_LIMIT);
|
const preview = (members ?? []).slice(0, PREVIEW_LIMIT);
|
||||||
@@ -72,11 +76,18 @@ export function GuildMembersSection({
|
|||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
{/* Оформление участника приходит уже вычисленным (AGENT.md 7.2). */}
|
{/* Оформление участника приходит уже вычисленным (AGENT.md 7.2). */}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
<Nickname
|
<Nickname
|
||||||
name={name}
|
name={name}
|
||||||
cosmetics={member.cosmetics}
|
cosmetics={member.cosmetics}
|
||||||
className="max-w-full text-sm font-medium"
|
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">
|
<span className="block truncate text-xs text-fg-muted">
|
||||||
{member.nickname === undefined || member.nickname === ''
|
{member.nickname === undefined || member.nickname === ''
|
||||||
? `@${member.username}`
|
? `@${member.username}`
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
|||||||
canBan={canBan}
|
canBan={canBan}
|
||||||
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
|
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
|
||||||
currentUserId={currentUser.data?.id}
|
currentUserId={currentUser.data?.id}
|
||||||
|
ownerId={guild.owner_id}
|
||||||
/>
|
/>
|
||||||
<GuildCosmeticsSection
|
<GuildCosmeticsSection
|
||||||
key={`${guild.id}-cosmetics`}
|
key={`${guild.id}-cosmetics`}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
}),
|
}),
|
||||||
resolveWebhook: () => ({
|
resolveWebhook: () => ({
|
||||||
@@ -109,6 +110,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
}),
|
}),
|
||||||
resolveMention: (userId) => ({
|
resolveMention: (userId) => ({
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
}),
|
}),
|
||||||
resolveWebhook: () => ({
|
resolveWebhook: () => ({
|
||||||
@@ -39,6 +40,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
}),
|
}),
|
||||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
|
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
}),
|
}),
|
||||||
resolveWebhook: () => ({
|
resolveWebhook: () => ({
|
||||||
@@ -80,6 +81,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
}),
|
}),
|
||||||
resolveMention: (userId) => ({ name: userId, isMe: false }),
|
resolveMention: (userId) => ({ name: userId, isMe: false }),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
}),
|
}),
|
||||||
resolveWebhook: () => ({
|
resolveWebhook: () => ({
|
||||||
@@ -27,6 +28,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
}),
|
}),
|
||||||
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
|
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
|
||||||
@@ -223,6 +225,7 @@ describe('сообщения вебхуков', () => {
|
|||||||
avatarUrl: message.webhook_avatar,
|
avatarUrl: message.webhook_avatar,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
|
|
||||||
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
||||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
|
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||||
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
|
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
|
||||||
import {
|
import {
|
||||||
bodyOf,
|
bodyOf,
|
||||||
@@ -498,3 +499,31 @@ describe('фон комнаты', () => {
|
|||||||
expect(image?.getAttribute('data-animated')).toBe('animated');
|
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,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: userId === 'user-2',
|
isMember: userId === 'user-2',
|
||||||
}),
|
}),
|
||||||
resolveWebhook: () => ({
|
resolveWebhook: () => ({
|
||||||
@@ -37,6 +38,7 @@ const authors: AuthorDirectory = {
|
|||||||
avatarFileId: undefined,
|
avatarFileId: undefined,
|
||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
}),
|
}),
|
||||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
|
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
|
||||||
|
|||||||
Reference in New Issue
Block a user