feat(profile): системные бейджи рядом с ником
- участники сервера отдают список бейджей из badges_json (AGENT.md 7.2): список расширяемый, назначается только системой - клиент: каталог бейджей (щит администратора инстанса, корона владельца, ранний сторонник, проверенный), неизвестные показываются нейтральной меткой - бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки - тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка)
This commit is contained in:
@@ -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">
|
||||
{/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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). */}
|
||||
<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`}
|
||||
|
||||
Reference in New Issue
Block a user