feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown

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

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+161
View File
@@ -0,0 +1,161 @@
import { afterEach, describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MessageContent } from '@/components/chat/MessageContent';
import { useSessionStore } from '@/stores/session';
import type { SessionGuild } from '@/stores/session';
afterEach(() => {
useSessionStore.getState().reset();
});
/** Разметка сообщения: React-узлы без dangerouslySetInnerHTML. */
function markup(content: string) {
return render(<MessageContent content={content} />);
}
describe('разметка: выделение и жирный курсив', () => {
it('==текст== превращается в mark', () => {
const { container } = markup('важно: ==выделение== да');
const mark = container.querySelector('mark');
expect(mark).toHaveTextContent('выделение');
expect(container.textContent).toBe('важно: выделение да');
});
it('__жирный__ работает как **жирный**', () => {
const { container } = markup('__жирный__ и **тоже**');
const strong = container.querySelectorAll('strong');
expect(strong).toHaveLength(2);
expect(strong[0]).toHaveTextContent('жирный');
expect(strong[1]).toHaveTextContent('тоже');
expect(container.textContent).toBe('жирный и тоже');
});
it('***оба*** — жирный курсив без лишних звёздочек', () => {
const { container } = markup('***оба***');
const strong = container.querySelector('strong');
expect(strong?.querySelector('em')).toHaveTextContent('оба');
expect(container.textContent).toBe('оба');
});
it('_курсив_ работает, но подчёркивания внутри слова — нет', () => {
const { container } = markup('_курсив_ и foo_bar_baz');
expect(container.querySelector('em')).toHaveTextContent('курсив');
expect(container.textContent).toBe('курсив и foo_bar_baz');
});
it('внутри кода маркеры не разбираются', () => {
const { container } = markup('`==не mark==` и блок:\n```\n**не жирный**\n```');
expect(container.querySelector('mark')).toBeNull();
expect(container.querySelector('strong')).toBeNull();
expect(container.querySelector('code')).toHaveTextContent('==не mark==');
expect(container.querySelector('pre')?.textContent).toContain('**не жирный**');
});
});
describe('разметка: незакрытые маркеры и безопасность', () => {
it('незакрытые маркеры остаются обычным текстом', () => {
const content = '==фыв **жирный *курсив ~~зач _подч ||спойлер `код';
const { container } = markup(content);
expect(container.textContent).toBe(content);
expect(container.querySelector('mark')).toBeNull();
expect(container.querySelector('strong')).toBeNull();
expect(container.querySelector('em')).toBeNull();
expect(container.querySelector('s')).toBeNull();
expect(container.querySelector('button')).toBeNull();
});
it('ссылка с опасным протоколом остаётся текстом', () => {
const { container } = markup('javascript:alert(1) и data:text/html,<b>x</b>');
expect(container.querySelector('a')).toBeNull();
expect(container.textContent).toContain('javascript:alert(1)');
expect(container.textContent).toContain('data:text/html,<b>x</b>');
});
it('HTML из сообщения не выполняется', () => {
const { container } = markup('<img src=x onerror=alert(1)> <script>alert(2)</script>');
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('<script>alert(2)</script>');
});
it('автоссылки http(s) открываются в новой вкладке', () => {
const { container } = markup('смотри https://example.com/a.');
const link = container.querySelector('a');
expect(link).toHaveAttribute('href', 'https://example.com/a');
expect(link).toHaveAttribute('rel', 'noreferrer noopener');
expect(link).toHaveAttribute('target', '_blank');
});
});
describe('разметка: спойлер', () => {
it('скрывает текст до клика и раскрывается', async () => {
const visitor = userEvent.setup();
const { container } = markup('секрет: ||тайна||');
const spoiler = container.querySelector('button');
expect(spoiler).toHaveTextContent('тайна');
expect(screen.getByRole('button', { name: 'Показать спойлер' })).toHaveAttribute(
'aria-pressed',
'false',
);
await visitor.click(screen.getByRole('button', { name: 'Показать спойлер' }));
expect(screen.getByRole('button', { name: 'Скрыть спойлер' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(container.textContent).toBe('секрет: тайна');
});
it('незакрытый спойлер остаётся текстом', () => {
const { container } = markup('||тайна');
expect(container.querySelector('button')).toBeNull();
expect(container.textContent).toBe('||тайна');
});
});
describe('разметка: упоминания каналов', () => {
const guild: SessionGuild = {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
};
it('подсвечивает канал из снапшота и не трогает неизвестный', () => {
useSessionStore.setState({ guilds: [guild] });
const { container } = markup('см. #общий и #неттакого');
const spans = Array.from(container.querySelectorAll('span'));
const known = spans.filter((span) => span.textContent === '#общий');
expect(known).toHaveLength(1);
expect(known[0]).toHaveClass('bg-accent/15');
expect(spans.some((span) => span.textContent === '#неттакого')).toBe(false);
expect(container.textContent).toBe('см. #общий и #неттакого');
});
it('без снапшота #канал остаётся обычным текстом', () => {
const { container } = markup('см. #общий');
expect(container.querySelectorAll('span')).toHaveLength(0);
expect(container.textContent).toBe('см. #общий');
});
});