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
+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>
);
}