feat(cosmetics): оформление «для друзей» в друзьях и личных беседах
- API: список друзей отдаёт вычисленное оформление «для друзей» одним запросом на весь список (AGENT.md 7.2) - клиент: рамка, иконка, цвет и эффект ника в списке друзей, в ленте личных бесед — из профиля собеседника - тесты: Go на список друзей, Vitest на отрисовку в друзьях
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { request } from './client';
|
||||
import type { MemberCosmetics } from './types';
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,8 @@ export interface RelationshipUser {
|
||||
/** Часовой пояс собеседника (для справки; время показываем в своём). */
|
||||
timezone: string;
|
||||
relationship: RelationshipKind;
|
||||
/** Оформление «для друзей»: применяется в DM и списке друзей (AGENT.md 7.2). */
|
||||
cosmetics?: MemberCosmetics;
|
||||
}
|
||||
|
||||
export interface Relationships {
|
||||
@@ -64,6 +67,32 @@ function asStringArray(value: unknown): string[] {
|
||||
}
|
||||
|
||||
/** Разбирает пользователя со связью; `null` — запись не по контракту. */
|
||||
/** Оформление «для друзей»: сервер уже применил приоритет (AGENT.md 7.2). */
|
||||
function asCosmetics(value: unknown): MemberCosmetics | undefined {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return undefined;
|
||||
}
|
||||
const cosmetics: MemberCosmetics = {};
|
||||
const frame = asString(record['frame_id']);
|
||||
const badge = asString(record['badge_id']);
|
||||
const color = record['nick_color'];
|
||||
const effect = asString(record['nick_effect']);
|
||||
if (frame !== undefined) {
|
||||
cosmetics.frame_id = frame;
|
||||
}
|
||||
if (badge !== undefined) {
|
||||
cosmetics.badge_id = badge;
|
||||
}
|
||||
if (typeof color === 'number') {
|
||||
cosmetics.nick_color = color;
|
||||
}
|
||||
if (effect !== undefined) {
|
||||
cosmetics.nick_effect = effect;
|
||||
}
|
||||
return Object.keys(cosmetics).length === 0 ? undefined : cosmetics;
|
||||
}
|
||||
|
||||
export function parseRelationshipUser(value: unknown): RelationshipUser | null {
|
||||
const record = asRecord(value);
|
||||
const userId = asString(record?.['user_id']);
|
||||
@@ -91,6 +120,10 @@ export function parseRelationshipUser(value: unknown): RelationshipUser | null {
|
||||
const avatar = asString(record['avatar_file_id']);
|
||||
const customStatus = asString(record['custom_status']);
|
||||
const lastSeenAt = asString(record['last_seen_at']);
|
||||
const cosmetics = asCosmetics(record['cosmetics']);
|
||||
if (cosmetics !== undefined) {
|
||||
user.cosmetics = cosmetics;
|
||||
}
|
||||
if (avatar !== undefined) {
|
||||
user.avatar_file_id = avatar;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface User {
|
||||
onboarding_completed: boolean;
|
||||
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
|
||||
totp_enabled: boolean;
|
||||
/** Оформление «для друзей» (AGENT.md 7.2): приходит в публичном профиле. */
|
||||
cosmetics?: MemberCosmetics;
|
||||
/** Дата регистрации: показывается в чужом профиле. */
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** Урезанное представление пользователя (участники сервера, READY). */
|
||||
@@ -40,6 +44,9 @@ export interface UserSummary {
|
||||
avatar_file_id?: string;
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
/** Оформление «для друзей»: `GET /users/{id}` отдаёт его вместе с профилем. */
|
||||
cosmetics?: MemberCosmetics;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
|
||||
@@ -140,6 +140,18 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
return map;
|
||||
}, [avatarsKey]);
|
||||
|
||||
// Оформление «для друзей» тех же авторов: в DM сервер отдаёт его в профиле.
|
||||
const userCosmeticsMap = useMemo(() => {
|
||||
const map = new Map<string, MemberCosmetics>();
|
||||
missing.forEach((userId, index) => {
|
||||
const cosmetics = users[index]?.data?.cosmetics;
|
||||
if (cosmetics !== undefined) {
|
||||
map.set(userId, cosmetics);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [missing, users]);
|
||||
|
||||
return useMemo<AuthorDirectory>(() => {
|
||||
const resolve = (userId: string | undefined): ChatAuthor => {
|
||||
if (userId === undefined || userId === '') {
|
||||
@@ -165,6 +177,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
};
|
||||
}
|
||||
const fallbackName = userNameMap.get(userId);
|
||||
const fallbackCosmetics = userCosmeticsMap.get(userId);
|
||||
return {
|
||||
id: userId,
|
||||
name: fallbackName ?? t('chat.unknownAuthor'),
|
||||
@@ -172,6 +185,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
...(fallbackCosmetics === undefined ? {} : { cosmetics: fallbackCosmetics }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -208,5 +222,14 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
},
|
||||
loaded: membersLoaded,
|
||||
};
|
||||
}, [memberMap, rolesData, userNameMap, userAvatarMap, currentUserId, membersLoaded, t]);
|
||||
}, [
|
||||
memberMap,
|
||||
rolesData,
|
||||
userNameMap,
|
||||
userAvatarMap,
|
||||
userCosmeticsMap,
|
||||
currentUserId,
|
||||
membersLoaded,
|
||||
t,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||
import { Popover } from '@/components/ui/Popover';
|
||||
@@ -77,11 +77,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
||||
data-testid={`friend-name-${friend.user_id}`}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Avatar
|
||||
<FramedAvatar
|
||||
name={displayName}
|
||||
seed={friend.user_id}
|
||||
fileId={friend.avatar_file_id}
|
||||
size="md"
|
||||
cosmetics={friend.cosmetics}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -92,7 +93,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{displayName}</span>
|
||||
{/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */}
|
||||
<Nickname
|
||||
name={displayName}
|
||||
cosmetics={friend.cosmetics}
|
||||
className="text-sm font-medium"
|
||||
/>
|
||||
<span className="block truncate text-xs text-fg-muted">@{friend.username}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user