feat(web): профиль с аватаром, панель в углу, настройки сервера, скрытые ID

Правки пользователя:

- в «Профиль → Основной» сверху квадратный аватар слева, справа от него
  настройки имени, «О себе», состояния и эмодзи во всю высоту; смена аватара —
  клик по нему, форматы (JPEG, PNG, WebP, GIF до 8 МБ) — в подсказке;
- убраны дубли: часовой пояс только в «Язык и регион», статус — только в
  мини-профиле панели;
- панель пользователя переехала в нижний левый угол под сайдбар, подсказка о
  выходе из сервера — на её прежнее место; панель ужата под 240 px;
- шестерёнка сервера открывает новый пункт настроек «Сервер» (список серверов,
  общее, участники, роли, опасная зона), а «Профиль → Сервер» оставлен только
  для ника и «Применить ко всем серверам»;
- окно настроек получило статический размер 70% рабочей области со скроллом;
- ID пользователей скрыты везде, кроме раздела «Инстанс» у администратора;
- список друзей свёрнут под кнопкой «Друзья (N)» и раскрывается снизу вверх;
- 249 тестов Vitest (было 239), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 11:24:09 +03:00
parent bd5f09336b
commit 4c8f888b3e
27 changed files with 1071 additions and 419 deletions
+98 -7
View File
@@ -109,6 +109,18 @@ async function dispatchEvent(
});
}
/**
* Раскрывает список друзей: по умолчанию он свёрнут под кнопкой
* «Друзья (N)» и разворачивается снизу вверх.
*/
async function expandFriends(): Promise<HTMLElement> {
const toggle = await screen.findByTestId('friends-list-toggle');
if (toggle.getAttribute('aria-expanded') !== 'true') {
await userEvent.click(toggle);
}
return screen.findByTestId('friends-list');
}
describe('раздел друзей', () => {
it('первой иконкой рейки открывает раздел друзей', async () => {
const visitor = userEvent.setup();
@@ -259,7 +271,8 @@ describe('раздел друзей', () => {
expect(
findRequest(fetchMock, { url: '/users/@me/relationships/user-3/accept', method: 'POST' }),
).toBeDefined();
expect(await screen.findByTestId('friends-list')).toHaveTextContent('Кэрол');
await expandFriends();
expect(screen.getByTestId('friends-list')).toHaveTextContent('Кэрол');
const outgoing = screen.getByTestId('friends-outgoing');
await visitor.click(within(outgoing).getByRole('button', { name: 'Отменить' }));
@@ -307,6 +320,84 @@ describe('раздел друзей', () => {
});
});
it('список друзей свёрнут по умолчанию и раскрывается кнопкой', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
baseRoutes({ ...empty, friends: [bob] }, [
{
match: '/api/v1/users/@me/channels',
method: 'POST',
response: () => json({ channel: directChannel('dm-1') }),
},
]),
);
const { router } = renderApp('/app/friends');
// Свёрнут: списка в DOM нет, кнопка сообщает состояние и число друзей.
const toggle = await screen.findByTestId('friends-list-toggle');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(toggle).toHaveTextContent('Друзья (1)');
expect(screen.queryByTestId('friends-list')).toBeNull();
expect(screen.getByTestId('friends-list-region')).toHaveAttribute('data-state', 'closed');
// Развёрнутый список въезжает снизу: класс анимации и состояние open.
await visitor.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
const region = screen.getByTestId('friends-list-region');
expect(region).toHaveAttribute('data-state', 'open');
expect(region).toHaveClass('gl-reveal-up');
// Развёрнутый список работает как раньше: переход в беседу по клику.
const list = await screen.findByTestId('friends-list');
await visitor.click(within(list).getByTestId('friend-name-user-2'));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/friends/dm-1');
});
expect(findRequest(fetchMock, { url: '/users/@me/channels', method: 'POST' })?.body).toEqual({
recipient_id: 'user-2',
});
// На странице беседы списка нет: состояние раскрытия переживает возврат.
expect(window.localStorage.getItem('glchat.friends.expanded')).toBe('true');
await visitor.click(screen.getByTestId('friends-rail-link'));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/friends');
});
// Свёрнутое состояние тоже запоминается.
await visitor.click(await screen.findByTestId('friends-list-toggle'));
expect(screen.queryByTestId('friends-list')).toBeNull();
expect(window.localStorage.getItem('glchat.friends.expanded')).toBe('false');
});
it('заявки в друзья остаются видимыми при свёрнутом списке', async () => {
installFetch(
baseRoutes({
...empty,
friends: [bob],
incoming: [
relationship({
user_id: 'user-3',
username: 'carol',
display_name: 'Кэрол',
relationship: 'incoming',
}),
],
}),
);
renderApp('/app/friends');
expect(await screen.findByTestId('friends-incoming')).toHaveTextContent('Кэрол');
expect(await screen.findByTestId('friends-list-toggle')).toHaveAttribute(
'aria-expanded',
'false',
);
// Бейджи непрочитанного в беседах живут в сайдбаре и от списка не зависят.
expect(screen.queryByTestId('friends-list')).toBeNull();
});
it('рисует цветные точки статусов в списке друзей', async () => {
installFetch(
baseRoutes({
@@ -348,7 +439,7 @@ describe('раздел друзей', () => {
);
renderApp('/app/friends');
const list = await screen.findByTestId('friends-list');
const list = await expandFriends();
const dots: [string, string, string][] = [
['user-2', 'online', 'bg-success'],
@@ -393,7 +484,7 @@ describe('раздел друзей', () => {
);
renderApp('/app/friends');
await screen.findByTestId('friends-list');
await expandFriends();
// Наведение на имя: 10:00 UTC — это 13:00 в Москве.
await visitor.hover(screen.getByTestId('friend-name-user-2'));
@@ -441,7 +532,7 @@ describe('раздел друзей', () => {
);
renderApp('/app/friends');
await screen.findByTestId('friends-list');
await expandFriends();
await visitor.hover(screen.getByTestId('friend-name-user-2'));
// 10:00 UTC — 15:00 в Екатеринбурге (UTC+5).
@@ -480,7 +571,7 @@ describe('раздел друзей', () => {
);
const { router } = renderApp('/app/friends');
const list = await screen.findByTestId('friends-list');
const list = await expandFriends();
await visitor.click(within(list).getByRole('button', { name: 'Написать' }));
await waitFor(() => {
@@ -664,7 +755,7 @@ describe('события шлюза о друзьях', () => {
hasFriend = true;
await dispatchEvent(gateway, 'RELATIONSHIP_UPDATE', { user_id: 'user-2' });
const list = await screen.findByTestId('friends-list');
const list = await expandFriends();
expect(list).toHaveTextContent('Боб');
});
@@ -685,7 +776,7 @@ describe('события шлюза о друзьях', () => {
);
renderApp('/app/friends');
const list = await screen.findByTestId('friends-list');
const list = await expandFriends();
await gateway.greet();
expect(within(list).getByTestId('friend-status-user-2')).toHaveAttribute(
'data-status',