feat(cosmetics): оформление «для друзей» в друзьях и личных беседах
- API: список друзей отдаёт вычисленное оформление «для друзей» одним запросом на весь список (AGENT.md 7.2) - клиент: рамка, иконка, цвет и эффект ника в списке друзей, в ленте личных бесед — из профиля собеседника - тесты: Go на список друзей, Vitest на отрисовку в друзьях
This commit is contained in:
@@ -29,6 +29,9 @@ type relationshipUser struct {
|
|||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
// Relationship: friend | incoming | outgoing | blocked | none
|
// Relationship: friend | incoming | outgoing | blocked | none
|
||||||
Relationship string `json:"relationship"`
|
Relationship string `json:"relationship"`
|
||||||
|
// Cosmetics — оформление «для друзей»: в DM и списке друзей применяется
|
||||||
|
// именно оно (AGENT.md 7.2).
|
||||||
|
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type relationshipListOutput struct {
|
type relationshipListOutput struct {
|
||||||
@@ -398,9 +401,20 @@ func (s *Server) relationshipProfiles(ctx context.Context, userID uint64, kind s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
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))
|
result := make([]relationshipUser, 0, len(profiles))
|
||||||
for i := range 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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,3 +348,48 @@ func TestSelfRoleAssignmentForCosmetics(t *testing.T) {
|
|||||||
t.Fatalf("кик себя = %d, ожидался 403", kick.Code)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -404,6 +404,27 @@ func (s *Store) EffectiveCosmeticsForGuild(ctx context.Context, guildID uint64)
|
|||||||
return result, nil
|
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 вычисляет оформление пользователя в контексте
|
// EffectiveCosmeticsForUser вычисляет оформление пользователя в контексте
|
||||||
// «для друзей» (DM, список друзей, глобальные места).
|
// «для друзей» (DM, список друзей, глобальные места).
|
||||||
func (s *Store) EffectiveCosmeticsForUser(ctx context.Context, userID uint64) (EffectiveCosmetics, error) {
|
func (s *Store) EffectiveCosmeticsForUser(ctx context.Context, userID uint64) (EffectiveCosmetics, error) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { request } from './client';
|
import { request } from './client';
|
||||||
|
import type { MemberCosmetics } from './types';
|
||||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +31,8 @@ export interface RelationshipUser {
|
|||||||
/** Часовой пояс собеседника (для справки; время показываем в своём). */
|
/** Часовой пояс собеседника (для справки; время показываем в своём). */
|
||||||
timezone: string;
|
timezone: string;
|
||||||
relationship: RelationshipKind;
|
relationship: RelationshipKind;
|
||||||
|
/** Оформление «для друзей»: применяется в DM и списке друзей (AGENT.md 7.2). */
|
||||||
|
cosmetics?: MemberCosmetics;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Relationships {
|
export interface Relationships {
|
||||||
@@ -64,6 +67,32 @@ function asStringArray(value: unknown): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Разбирает пользователя со связью; `null` — запись не по контракту. */
|
/** Разбирает пользователя со связью; `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 {
|
export function parseRelationshipUser(value: unknown): RelationshipUser | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
const userId = asString(record?.['user_id']);
|
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 avatar = asString(record['avatar_file_id']);
|
||||||
const customStatus = asString(record['custom_status']);
|
const customStatus = asString(record['custom_status']);
|
||||||
const lastSeenAt = asString(record['last_seen_at']);
|
const lastSeenAt = asString(record['last_seen_at']);
|
||||||
|
const cosmetics = asCosmetics(record['cosmetics']);
|
||||||
|
if (cosmetics !== undefined) {
|
||||||
|
user.cosmetics = cosmetics;
|
||||||
|
}
|
||||||
if (avatar !== undefined) {
|
if (avatar !== undefined) {
|
||||||
user.avatar_file_id = avatar;
|
user.avatar_file_id = avatar;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface User {
|
|||||||
onboarding_completed: boolean;
|
onboarding_completed: boolean;
|
||||||
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
|
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
|
||||||
totp_enabled: boolean;
|
totp_enabled: boolean;
|
||||||
|
/** Оформление «для друзей» (AGENT.md 7.2): приходит в публичном профиле. */
|
||||||
|
cosmetics?: MemberCosmetics;
|
||||||
|
/** Дата регистрации: показывается в чужом профиле. */
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Урезанное представление пользователя (участники сервера, READY). */
|
/** Урезанное представление пользователя (участники сервера, READY). */
|
||||||
@@ -40,6 +44,9 @@ export interface UserSummary {
|
|||||||
avatar_file_id?: string;
|
avatar_file_id?: string;
|
||||||
is_instance_admin: boolean;
|
is_instance_admin: boolean;
|
||||||
badges: string[];
|
badges: string[];
|
||||||
|
/** Оформление «для друзей»: `GET /users/{id}` отдаёт его вместе с профилем. */
|
||||||
|
cosmetics?: MemberCosmetics;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
return map;
|
return map;
|
||||||
}, [avatarsKey]);
|
}, [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>(() => {
|
return useMemo<AuthorDirectory>(() => {
|
||||||
const resolve = (userId: string | undefined): ChatAuthor => {
|
const resolve = (userId: string | undefined): ChatAuthor => {
|
||||||
if (userId === undefined || userId === '') {
|
if (userId === undefined || userId === '') {
|
||||||
@@ -165,6 +177,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const fallbackName = userNameMap.get(userId);
|
const fallbackName = userNameMap.get(userId);
|
||||||
|
const fallbackCosmetics = userCosmeticsMap.get(userId);
|
||||||
return {
|
return {
|
||||||
id: userId,
|
id: userId,
|
||||||
name: fallbackName ?? t('chat.unknownAuthor'),
|
name: fallbackName ?? t('chat.unknownAuthor'),
|
||||||
@@ -172,6 +185,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
colorHex: null,
|
colorHex: null,
|
||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
isMember: false,
|
isMember: false,
|
||||||
|
...(fallbackCosmetics === undefined ? {} : { cosmetics: fallbackCosmetics }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,5 +222,14 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
},
|
},
|
||||||
loaded: membersLoaded,
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
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 { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||||
import { Popover } from '@/components/ui/Popover';
|
import { Popover } from '@/components/ui/Popover';
|
||||||
@@ -77,11 +77,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|||||||
data-testid={`friend-name-${friend.user_id}`}
|
data-testid={`friend-name-${friend.user_id}`}
|
||||||
>
|
>
|
||||||
<span className="relative shrink-0">
|
<span className="relative shrink-0">
|
||||||
<Avatar
|
<FramedAvatar
|
||||||
name={displayName}
|
name={displayName}
|
||||||
seed={friend.user_id}
|
seed={friend.user_id}
|
||||||
fileId={friend.avatar_file_id}
|
fileId={friend.avatar_file_id}
|
||||||
size="md"
|
size="md"
|
||||||
|
cosmetics={friend.cosmetics}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -92,7 +93,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<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 className="block truncate text-xs text-fg-muted">@{friend.username}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
recordedRequests,
|
recordedRequests,
|
||||||
renderApp,
|
renderApp,
|
||||||
resetStores,
|
resetStores,
|
||||||
|
waitForPage,
|
||||||
type FetchRoute,
|
type FetchRoute,
|
||||||
} from './helpers';
|
} 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user