feat(web): карточка превью ссылки в ленте (Фаза 7)
Под текстом сообщения показывается карточка: имя сайта, заголовок, описание и картинка — всё с сервера (`GET /link-previews`), клиент сам в интернет не ходит. - `web/src/lib/links.ts`: первая ссылка сообщения (markdown и «голая» формы), блоки кода и инлайновый код игнорируются — `curl https://…` из примера не должен тянуть чужую страницу; - `web/src/components/chat/LinkPreviewCard.tsx`: запросов нет, пока `meta.features.unfurl_enabled` не подтверждён; карточка молчит на loading/ошибке/статусах empty, blocked и error (превью — украшение, а не действие); картинка рисуется только по http(s) с `referrerpolicy=no-referrer`, текст выводится обычными строками без `dangerouslySetInnerHTML`; компонент монтируется только при найденной ссылке, поэтому сообщение без ссылок не создаёт ни запроса, ни узла; - `web/src/api/unfurl.ts`: типизированный клиент и ключ запроса. Тесты: `web/tests/links.test.ts` (разбор ссылок) и `web/tests/linkPreview.test.tsx` (карточка, отказ при выключенном unfurl, опасные схемы картинки, ровно один запрос при 429).
This commit is contained in:
@@ -20,6 +20,7 @@ const meta: InstanceMeta = {
|
||||
anti_bot_enabled: false,
|
||||
voice_enabled: false,
|
||||
web_push_enabled: false,
|
||||
unfurl_enabled: false,
|
||||
passkeys_enabled: false,
|
||||
oauth_enabled: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { Message } from '@/api/messages';
|
||||
import { metaQueryKey } from '@/api/meta';
|
||||
import { linkPreviewQueryKey, type LinkPreviewStatus } from '@/api/unfurl';
|
||||
import { LinkPreviewCard } from '@/components/chat/LinkPreviewCard';
|
||||
import { MessageList } from '@/components/chat/MessageList';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import {
|
||||
createTestQueryClient,
|
||||
findRequest,
|
||||
installFetch,
|
||||
json,
|
||||
makeMessage,
|
||||
recordedRequests,
|
||||
type FetchRoute,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* Карточка превью ссылки (Фаза 7): показывает заголовок и ведёт по ссылке,
|
||||
* молчит на `empty`/`blocked`/`error`, не подставляет картинку с чужой схемой
|
||||
* и вообще не запрашивает превью, пока инстанс не подтвердил `unfurl_enabled`.
|
||||
*/
|
||||
|
||||
const url = 'https://example.com/page';
|
||||
|
||||
/** Карточка, какой её отдаёт сервер: адрес канонизирован, поля необязательны. */
|
||||
const preview = {
|
||||
url: 'https://example.com/canonical',
|
||||
title: 'Заголовок страницы',
|
||||
description: 'Короткое описание страницы',
|
||||
site_name: 'Example',
|
||||
image_url: 'https://cdn.example.com/cover.png',
|
||||
};
|
||||
|
||||
interface CardOptions {
|
||||
/** Ответ ручки превью; по умолчанию — карточка со статусом `ok`. */
|
||||
response?: () => Response;
|
||||
/** Флаг инстанса `meta.features.unfurl_enabled`. */
|
||||
unfurlEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Маршруты `/meta` и `/link-previews` (остальное моку незнакомо). */
|
||||
function cardRoutes(options: CardOptions = {}): FetchRoute[] {
|
||||
return [
|
||||
{
|
||||
match: '/api/v1/meta',
|
||||
response: () => json({ features: { unfurl_enabled: options.unfurlEnabled ?? true } }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/link-previews',
|
||||
response: options.response ?? (() => json({ status: 'ok', preview })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function renderCard(targetUrl: string | null, options: CardOptions = {}) {
|
||||
const fetchMock = installFetch(cardRoutes(options));
|
||||
const client = createTestQueryClient();
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<LinkPreviewCard url={targetUrl} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, fetchMock, client };
|
||||
}
|
||||
|
||||
/** Ждём, пока запрос превью завершится с нужным исходом. */
|
||||
async function waitForPreview(
|
||||
client: QueryClient,
|
||||
status: 'success' | 'error' = 'success',
|
||||
target = url,
|
||||
): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(client.getQueryState(linkPreviewQueryKey(target))?.status).toBe(status);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ответ ручки превью с заданным статусом (без карточки). */
|
||||
function statusResponse(status: LinkPreviewStatus): () => Response {
|
||||
return () => json({ status });
|
||||
}
|
||||
|
||||
describe('карточка превью ссылки', () => {
|
||||
it('показывает заголовок и ведёт по адресу из ответа', async () => {
|
||||
const { fetchMock, client } = renderCard(url);
|
||||
|
||||
const link = await screen.findByTestId('link-preview');
|
||||
expect(within(link).getByText('Заголовок страницы')).toBeVisible();
|
||||
expect(within(link).getByText('Короткое описание страницы')).toBeVisible();
|
||||
expect(within(link).getByText('Example')).toBeVisible();
|
||||
|
||||
// Ссылка ведёт туда, куда указал сервер (он мог канонизировать адрес).
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/canonical');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noopener');
|
||||
expect(link.getAttribute('rel')).toContain('noreferrer');
|
||||
expect(link).toHaveAttribute('title', 'Открыть ссылку');
|
||||
|
||||
// Картинка: только по проверенному адресу, без referer и с ленивой загрузкой.
|
||||
const image = within(link).getByRole('img');
|
||||
expect(image).toHaveAttribute('src', 'https://cdn.example.com/cover.png');
|
||||
expect(image).toHaveAttribute('alt', 'Картинка превью');
|
||||
expect(image).toHaveAttribute('loading', 'lazy');
|
||||
expect(image).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
|
||||
expect(
|
||||
findRequest(fetchMock, {
|
||||
url: `/api/v1/link-previews?url=${encodeURIComponent(url)}`,
|
||||
}),
|
||||
).toBeDefined();
|
||||
await waitForPreview(client);
|
||||
});
|
||||
|
||||
it.each(['javascript:alert(1)', 'data:image/png;base64,AAAA'])(
|
||||
'не подставляет картинку со схемой %s',
|
||||
async (imageUrl) => {
|
||||
const { client } = renderCard(url, {
|
||||
response: () => json({ status: 'ok', preview: { ...preview, image_url: imageUrl } }),
|
||||
});
|
||||
|
||||
const link = await screen.findByTestId('link-preview');
|
||||
expect(within(link).getByText('Заголовок страницы')).toBeVisible();
|
||||
expect(within(link).queryByRole('img')).toBeNull();
|
||||
await waitForPreview(client);
|
||||
},
|
||||
);
|
||||
|
||||
it('не показывает карточку без метаданных', async () => {
|
||||
const { client } = renderCard(url, { response: statusResponse('empty') });
|
||||
|
||||
await waitForPreview(client);
|
||||
expect(screen.queryByTestId('link-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('не показывает карточку при сбое загрузки', async () => {
|
||||
const { client } = renderCard(url, { response: statusResponse('error') });
|
||||
|
||||
await waitForPreview(client);
|
||||
expect(screen.queryByTestId('link-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('не показывает карточку, если сервер запретил адрес', async () => {
|
||||
const { client } = renderCard(url, { response: statusResponse('blocked') });
|
||||
|
||||
await waitForPreview(client);
|
||||
expect(screen.queryByTestId('link-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('не показывает карточку, если карточка пустая', async () => {
|
||||
const { client } = renderCard(url, {
|
||||
response: () => json({ status: 'ok', preview: { url: preview.url } }),
|
||||
});
|
||||
|
||||
await waitForPreview(client);
|
||||
expect(screen.queryByTestId('link-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('молчит на ошибку ручки и не повторяет запрос', async () => {
|
||||
const { fetchMock, client } = renderCard(url, {
|
||||
response: () =>
|
||||
json(
|
||||
{ error: { code: 'rate_limited', message: 'слишком часто', retry_after_ms: 1000 } },
|
||||
429,
|
||||
),
|
||||
});
|
||||
|
||||
await waitForPreview(client, 'error');
|
||||
expect(screen.queryByTestId('link-preview')).toBeNull();
|
||||
expect(
|
||||
recordedRequests(fetchMock).filter((request) => request.url.includes('link-previews')),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('без ссылки не рисует карточку и ничего не запрашивает', () => {
|
||||
const { fetchMock } = renderCard(null);
|
||||
|
||||
expect(screen.queryByTestId('link-preview')).toBeNull();
|
||||
expect(recordedRequests(fetchMock)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('не запрашивает превью, если unfurl выключен на инстансе', async () => {
|
||||
const { fetchMock, client } = renderCard(url, { unfurlEnabled: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.getQueryState(metaQueryKey)?.status).toBe('success');
|
||||
});
|
||||
expect(
|
||||
recordedRequests(fetchMock).filter((request) => request.url.includes('link-previews')),
|
||||
).toHaveLength(0);
|
||||
expect(screen.queryByTestId('link-preview')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('карточка превью в ленте', () => {
|
||||
const authors: AuthorDirectory = {
|
||||
resolve: () => ({
|
||||
id: 'user-2',
|
||||
name: 'Bob',
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveWebhook: () => ({
|
||||
id: '',
|
||||
name: 'вебхук',
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId, isMe: false }),
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
function renderList(messages: Message[]) {
|
||||
const fetchMock = installFetch(cardRoutes());
|
||||
const client = createTestQueryClient();
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MessageList
|
||||
channelId="c-1"
|
||||
channelName="общий"
|
||||
messages={messages}
|
||||
status="ready"
|
||||
error={null}
|
||||
hasMore={false}
|
||||
loadingMore={false}
|
||||
unreadAnchorId={null}
|
||||
highlightId={null}
|
||||
authors={authors}
|
||||
currentUserId="user-1"
|
||||
canManageMessages
|
||||
canSend
|
||||
onReload={() => undefined}
|
||||
onLoadMore={() => undefined}
|
||||
onReply={() => undefined}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, fetchMock, client };
|
||||
}
|
||||
|
||||
it('рисуется под обычным сообщением и не рисуется под системным', async () => {
|
||||
renderList([
|
||||
makeMessage({
|
||||
id: 'm-1',
|
||||
channel_id: 'c-1',
|
||||
author_id: 'user-2',
|
||||
content: 'смотри https://example.com/page',
|
||||
}),
|
||||
makeMessage({
|
||||
id: 'm-2',
|
||||
channel_id: 'c-1',
|
||||
type: 'system',
|
||||
content: 'https://example.com/page',
|
||||
}),
|
||||
]);
|
||||
|
||||
const message = await screen.findByTestId('message-m-1');
|
||||
const link = await within(message).findByTestId('link-preview');
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/canonical');
|
||||
|
||||
// Системное сообщение карточку не получает, даже если в тексте есть ссылка.
|
||||
const system = screen.getByTestId('message-m-2');
|
||||
expect(within(system).queryByTestId('link-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('не показывает карточку и не запрашивает превью у сообщения без ссылки', async () => {
|
||||
const { fetchMock } = renderList([
|
||||
makeMessage({ id: 'm-3', channel_id: 'c-1', author_id: 'user-2', content: 'просто текст' }),
|
||||
]);
|
||||
|
||||
const message = await screen.findByTestId('message-m-3');
|
||||
expect(within(message).queryByTestId('link-preview')).toBeNull();
|
||||
// Ссылки нет — карточка не монтируется, запросов нет вовсе.
|
||||
expect(recordedRequests(fetchMock)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { firstLink } from '@/lib/links';
|
||||
|
||||
/**
|
||||
* Первая ссылка сообщения: markdown-разметка, «голые» адреса и всё, что
|
||||
* превьюшить нельзя, — код, чужие схемы, хвостовая пунктуация.
|
||||
*/
|
||||
describe('firstLink', () => {
|
||||
it('находит адрес в markdown-ссылке', () => {
|
||||
expect(firstLink('смотри [документацию](https://example.com/docs) там')).toBe(
|
||||
'https://example.com/docs',
|
||||
);
|
||||
});
|
||||
|
||||
it('находит «голую» ссылку', () => {
|
||||
expect(firstLink('новость тут https://example.com/news и всё')).toBe(
|
||||
'https://example.com/news',
|
||||
);
|
||||
expect(firstLink('http://example.com')).toBe('http://example.com');
|
||||
});
|
||||
|
||||
it('возвращает первую ссылку и не смотрит на следующие', () => {
|
||||
expect(firstLink('https://one.example и https://two.example')).toBe('https://one.example');
|
||||
expect(firstLink('первая строка\nhttps://second.example')).toBe('https://second.example');
|
||||
});
|
||||
|
||||
it('в `[https://a](https://b)` берёт адрес разметки, а не текст ссылки', () => {
|
||||
expect(firstLink('[https://текст.example](https://цель.example)')).toBe('https://цель.example');
|
||||
});
|
||||
|
||||
it('пропускает блок кода', () => {
|
||||
expect(firstLink('```\nhttps://code.example\n```')).toBeNull();
|
||||
expect(firstLink('```bash\ncurl https://code.example\n```\nа вот https://real.example')).toBe(
|
||||
'https://real.example',
|
||||
);
|
||||
});
|
||||
|
||||
it('пропускает инлайновый код', () => {
|
||||
expect(firstLink('`https://code.example`')).toBeNull();
|
||||
expect(firstLink('в примере `https://code.example`, а живьём https://real.example')).toBe(
|
||||
'https://real.example',
|
||||
);
|
||||
});
|
||||
|
||||
it('находит ссылку в цитате', () => {
|
||||
expect(firstLink('> https://quoted.example')).toBe('https://quoted.example');
|
||||
});
|
||||
|
||||
it('обрезает хвостовую пунктуацию', () => {
|
||||
expect(firstLink('ссылка https://example.com/page.')).toBe('https://example.com/page');
|
||||
expect(firstLink('ссылка https://example.com/page, и ещё')).toBe('https://example.com/page');
|
||||
expect(firstLink('«https://example.com/page»')).toBe('https://example.com/page');
|
||||
expect(firstLink('что это: https://example.com/page?!')).toBe('https://example.com/page');
|
||||
});
|
||||
|
||||
it('обрезает markdown-обёртки', () => {
|
||||
expect(firstLink('**https://example.com/bold**')).toBe('https://example.com/bold');
|
||||
expect(firstLink('~~https://example.com/strike~~')).toBe('https://example.com/strike');
|
||||
expect(firstLink('_https://example.com/italic_')).toBe('https://example.com/italic');
|
||||
});
|
||||
|
||||
it('не считает ссылкой адрес без хоста', () => {
|
||||
expect(firstLink('просто https://')).toBeNull();
|
||||
expect(firstLink('просто https://.')).toBeNull();
|
||||
});
|
||||
|
||||
it('игнорирует другие схемы', () => {
|
||||
expect(firstLink('ftp://example.com/file.txt')).toBeNull();
|
||||
expect(firstLink('javascript:alert(1)')).toBeNull();
|
||||
expect(firstLink('mailto:user@example.com')).toBeNull();
|
||||
expect(firstLink('[клик](javascript:alert(1))')).toBeNull();
|
||||
});
|
||||
|
||||
it('возвращает null, если ссылки нет', () => {
|
||||
expect(firstLink('обычный текст без адресов')).toBeNull();
|
||||
expect(firstLink('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -309,6 +309,7 @@ const metaWithPasskeys = {
|
||||
anti_bot_enabled: false,
|
||||
voice_enabled: false,
|
||||
web_push_enabled: false,
|
||||
unfurl_enabled: false,
|
||||
oauth_enabled: true,
|
||||
passkeys_enabled: true,
|
||||
oauth_providers: ['github'],
|
||||
|
||||
@@ -76,6 +76,7 @@ const enabledMeta: InstanceMeta = {
|
||||
anti_bot_enabled: false,
|
||||
voice_enabled: true,
|
||||
web_push_enabled: false,
|
||||
unfurl_enabled: false,
|
||||
passkeys_enabled: false,
|
||||
oauth_enabled: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user