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