From a36ee9527bdaeb568600bbe1cfb750e36dd1af01 Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 01:48:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(cosmetics):=20=D0=BE=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=C2=AB=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B4=D1=80=D1=83=D0=B7=D0=B5=D0=B9=C2=BB=20=D0=B2=20=D0=B4?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D1=8C=D1=8F=D1=85=20=D0=B8=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D1=85=20=D0=B1=D0=B5=D1=81=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: список друзей отдаёт вычисленное оформление «для друзей» одним запросом на весь список (AGENT.md 7.2) - клиент: рамка, иконка, цвет и эффект ника в списке друзей, в ленте личных бесед — из профиля собеседника - тесты: Go на список друзей, Vitest на отрисовку в друзьях --- internal/server/api_social.go | 16 ++++++- internal/server/cosmetics_test.go | 45 +++++++++++++++++++ internal/store/cosmetics.go | 21 +++++++++ web/src/api/friends.ts | 33 ++++++++++++++ web/src/api/types.ts | 7 +++ web/src/components/chat/useAuthorDirectory.ts | 25 ++++++++++- web/src/components/friends/FriendRow.tsx | 12 +++-- web/tests/profileCosmetics.test.tsx | 38 ++++++++++++++++ 8 files changed, 192 insertions(+), 5 deletions(-) diff --git a/internal/server/api_social.go b/internal/server/api_social.go index 0383772..269269e 100644 --- a/internal/server/api_social.go +++ b/internal/server/api_social.go @@ -29,6 +29,9 @@ type relationshipUser struct { Timezone string `json:"timezone"` // Relationship: friend | incoming | outgoing | blocked | none Relationship string `json:"relationship"` + // Cosmetics — оформление «для друзей»: в DM и списке друзей применяется + // именно оно (AGENT.md 7.2). + Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"` } type relationshipListOutput struct { @@ -398,9 +401,20 @@ func (s *Server) relationshipProfiles(ctx context.Context, userID uint64, kind s if err != nil { return nil, humaError(err) } + // Оформление «для друзей» (AGENT.md 7.2) — одним запросом на весь список. + ids := make([]uint64, 0, len(profiles)) + for i := range profiles { + ids = append(ids, profiles[i].UserID) + } + cosmetics, err := s.store.EffectiveCosmeticsForUsers(ctx, ids) + if err != nil { + return nil, humaError(err) + } result := make([]relationshipUser, 0, len(profiles)) for i := range profiles { - result = append(result, s.relationshipPayload(userID, &profiles[i], string(kind))) + payload := s.relationshipPayload(userID, &profiles[i], string(kind)) + payload.Cosmetics = cosmeticsFromEffective(cosmetics[profiles[i].UserID]) + result = append(result, payload) } return result, nil } diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index 3d25208..0ed8b71 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -348,3 +348,48 @@ func TestSelfRoleAssignmentForCosmetics(t *testing.T) { t.Fatalf("кик себя = %d, ожидался 403", kick.Code) } } + +func TestFriendsListCarriesCosmetics(t *testing.T) { + srv, owner, member, guildID, memberID := cosmeticsFixture(t) + + // Стиль «для друзей» с эффектом и цветом из палитры. + style := doJSON(t, srv, http.MethodPut, styleScopePath("global"), + `{"nick_color":3066993,"nick_effect":"gradient"}`, member) + if style.Code != http.StatusOK { + t.Fatalf("стиль «для друзей» = %d (%s)", style.Code, style.Body.String()) + } + + // Дружба в обе стороны. + request := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", + `{"user_id":"`+memberID+`"}`, owner) + if request.Code != http.StatusOK && request.Code != http.StatusCreated { + t.Fatalf("заявка в друзья = %d (%s)", request.Code, request.Body.String()) + } + 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 + accept := doJSON(t, srv, http.MethodPost, + "/api/v1/users/@me/relationships/"+ownerID+"/accept", "", member) + if accept.Code != http.StatusOK { + t.Fatalf("принятие заявки = %d (%s)", accept.Code, accept.Body.String()) + } + + friends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", owner) + list := decodeResponse[struct { + Friends []struct { + UserID string `json:"user_id"` + Cosmetics *effectiveCosmetics `json:"cosmetics"` + } `json:"friends"` + }](t, friends) + if len(list.Friends) != 1 { + t.Fatalf("друзей %d, ожидался 1 (%s)", len(list.Friends), friends.Body.String()) + } + got := list.Friends[0].Cosmetics + if got == nil || got.NickEffect != "gradient" || got.NickColor == nil || *got.NickColor != 3066993 { + t.Fatalf("оформление друга = %+v", got) + } + _ = guildID +} diff --git a/internal/store/cosmetics.go b/internal/store/cosmetics.go index e7234fa..ba9cb07 100644 --- a/internal/store/cosmetics.go +++ b/internal/store/cosmetics.go @@ -404,6 +404,27 @@ func (s *Store) EffectiveCosmeticsForGuild(ctx context.Context, guildID uint64) return result, nil } +// EffectiveCosmeticsForUsers вычисляет оформление «для друзей» для пачки +// пользователей: так список друзей и DM получают его одним запросом. +func (s *Store) EffectiveCosmeticsForUsers(ctx context.Context, userIDs []uint64) (map[uint64]EffectiveCosmetics, error) { + result := make(map[uint64]EffectiveCosmetics, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + styles, err := s.listUserStylesFor(ctx, userIDs, 0) + if err != nil { + return nil, err + } + grants, err := s.CosmeticGrantsForUsers(ctx, userIDs, nil) + if err != nil { + return nil, err + } + for _, userID := range userIDs { + result[userID] = resolveCosmetics(nil, nil, nil, styles.global[userID], grants[userID]) + } + return result, nil +} + // EffectiveCosmeticsForUser вычисляет оформление пользователя в контексте // «для друзей» (DM, список друзей, глобальные места). func (s *Store) EffectiveCosmeticsForUser(ctx context.Context, userID uint64) (EffectiveCosmetics, error) { diff --git a/web/src/api/friends.ts b/web/src/api/friends.ts index 19b9344..5d6e7f0 100644 --- a/web/src/api/friends.ts +++ b/web/src/api/friends.ts @@ -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; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3042266..99fba57 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 { diff --git a/web/src/components/chat/useAuthorDirectory.ts b/web/src/components/chat/useAuthorDirectory.ts index fbb813d..5403eef 100644 --- a/web/src/components/chat/useAuthorDirectory.ts +++ b/web/src/components/chat/useAuthorDirectory.ts @@ -140,6 +140,18 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A return map; }, [avatarsKey]); + // Оформление «для друзей» тех же авторов: в DM сервер отдаёт его в профиле. + const userCosmeticsMap = useMemo(() => { + const map = new Map(); + missing.forEach((userId, index) => { + const cosmetics = users[index]?.data?.cosmetics; + if (cosmetics !== undefined) { + map.set(userId, cosmetics); + } + }); + return map; + }, [missing, users]); + return useMemo(() => { 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, + ]); } diff --git a/web/src/components/friends/FriendRow.tsx b/web/src/components/friends/FriendRow.tsx index 79e472b..a352135 100644 --- a/web/src/components/friends/FriendRow.tsx +++ b/web/src/components/friends/FriendRow.tsx @@ -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}`} > - - {displayName} + {/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */} + @{friend.username} diff --git a/web/tests/profileCosmetics.test.tsx b/web/tests/profileCosmetics.test.tsx index eb0b4a4..4209f83 100644 --- a/web/tests/profileCosmetics.test.tsx +++ b/web/tests/profileCosmetics.test.tsx @@ -13,6 +13,7 @@ import { recordedRequests, renderApp, resetStores, + waitForPage, type FetchRoute, } from './helpers'; @@ -284,3 +285,40 @@ describe('галерея оформления сервера', () => { }); }); }); + +describe('оформление «для друзей»', () => { + it('показывает рамку, иконку и эффект в списке друзей', async () => { + const friend = { + user_id: 'u-2', + username: 'bob', + display_name: 'Боб', + status: 'online', + visible_status: 'online', + is_instance_admin: false, + badges: [], + timezone: 'Europe/Moscow', + relationship: 'friend', + cosmetics: { frame_id: 'f-9', badge_id: 'b-9', nick_color: 0x2ecc71, nick_effect: 'glow' }, + }; + installFetch([ + { + match: '/api/v1/users/@me/relationships', + response: () => json({ friends: [friend], incoming: [], outgoing: [], blocked: [] }), + }, + { match: '/api/v1/users/@me/channels', response: () => json({ channels: [] }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + ]); + renderApp('/app/friends'); + await waitForPage('Друзья'); + // Список друзей свёрнут по умолчанию: раскрываем его перед проверкой. + await userEvent.click(await screen.findByTestId('friends-list-toggle')); + + const row = await screen.findByTestId('friend-name-u-2'); + expect(within(row).getByTestId('avatar-frame')).toBeInTheDocument(); + expect(within(row).getByTestId('nick-badge').getAttribute('src')).toBe('/files/b-9'); + expect(within(row).getByTestId('nick-name')).toHaveClass('gl-nick-glow'); + expect(within(row).getByTestId('nick-name')).toHaveStyle({ color: '#2ecc71' }); + }); +});