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:
2026-09-26 16:15:54 +03:00
parent f45cc21a9c
commit 45a5eaae64
12 changed files with 708 additions and 4 deletions
+2
View File
@@ -7,6 +7,8 @@ export interface InstanceFeatures {
web_push_enabled: boolean;
oauth_enabled: boolean;
passkeys_enabled: boolean;
/** Превью ссылок в сообщениях (Фаза 7): карточку готовит сервер. */
unfurl_enabled: boolean;
}
export interface InstanceMeta {
+102
View File
@@ -0,0 +1,102 @@
import { request } from './client';
/**
* Серверные превью ссылок (Фаза 7 AGENT.md): `GET /link-previews?url=…`.
*
* Карточку целиком готовит сервер: он же ходит по ссылке, проверяет адрес
* (только http/https, без внутренней сети) и кэширует результат. Клиент лишь
* разбирает ответ и не доверяет полям: адрес картинки ещё раз проверяется на
* схему в `LinkPreviewCard`.
*
* Ответы ручки:
* - `ok` — карточка есть (любое из полей, кроме `url`, может отсутствовать);
* - `empty` — метаданных нет; `blocked` — адрес запрещён; `error` — не удалось
* загрузить. Во всех трёх случаях карточку не показываем.
*
* Ошибки (`422 unfurl.invalid_url`, `429` с `retry_after_ms`, `503
* unfurl.disabled`) приходят конвертом `{error:{code,message}}` и поднимаются
* как `ApiError`; карточка на них просто не рисуется.
*/
/** Статус ответа: карточку показываем только при `ok`. */
export type LinkPreviewStatus = 'ok' | 'empty' | 'blocked' | 'error';
export interface LinkPreview {
url: string;
title?: string;
description?: string;
site_name?: string;
image_url?: string;
}
export interface LinkPreviewResponse {
status: LinkPreviewStatus;
preview?: LinkPreview;
}
/** Ключ запроса: одна ссылка — одна запись кэша. */
export function linkPreviewQueryKey(url: string): readonly ['link-preview', string] {
return ['link-preview', url] as const;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
/**
* Разбор карточки. Без `url` она бесполезна (ссылка в ней и есть содержимое),
* поэтому такую запись отбрасываем целиком; остальные поля необязательны.
*/
export function parseLinkPreview(value: unknown): LinkPreview | undefined {
const record = asRecord(value);
if (record === null) {
return undefined;
}
const url = asString(record['url']);
if (url === undefined) {
return undefined;
}
const preview: LinkPreview = { url };
const title = asString(record['title']);
const description = asString(record['description']);
const siteName = asString(record['site_name']);
const imageUrl = asString(record['image_url']);
if (title !== undefined) {
preview.title = title;
}
if (description !== undefined) {
preview.description = description;
}
if (siteName !== undefined) {
preview.site_name = siteName;
}
if (imageUrl !== undefined) {
preview.image_url = imageUrl;
}
return preview;
}
/** Неизвестный статус — это сбой: карточку по нему не показываем. */
function asStatus(value: unknown): LinkPreviewStatus {
return value === 'ok' || value === 'empty' || value === 'blocked' || value === 'error'
? value
: 'error';
}
export async function fetchLinkPreview(
url: string,
signal?: AbortSignal,
): Promise<LinkPreviewResponse> {
const payload = await request<unknown>(
`/link-previews?url=${encodeURIComponent(url)}`,
signal === undefined ? {} : { signal },
);
const record = asRecord(payload);
const status = asStatus(record?.['status']);
const preview = parseLinkPreview(record?.['preview']);
return preview === undefined ? { status } : { status, preview };
}
+117
View File
@@ -0,0 +1,117 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { fetchLinkPreview, linkPreviewQueryKey } from '@/api/unfurl';
/**
* Карточка превью ссылки из сообщения (Фаза 7 AGENT.md).
*
* Всё содержимое готовит сервер (`GET /link-previews`): заголовок, описание,
* имя сайта и адрес картинки. Клиент ничего не скачивает сам и не доверяет
* ответу: картинка рисуется только по http(s), а текст выводится обычными
* строками (никакого `dangerouslySetInnerHTML`).
*
* Запросов нет, пока инстанс не подтвердил флаг `meta.features.unfurl_enabled`:
* выключенный unfurl не должен ходить в интернет даже случайно.
*
* Компонент монтируется только при найденной ссылке (`firstLink`): сообщение
* без ссылок не создаёт ни запроса, ни лишнего узла в ленте.
*/
/** Схема адреса, который можно подставлять в `src`/`href`. */
const HTTP_URL_PATTERN = /^https?:\/\//iu;
export interface LinkPreviewCardProps {
/** Первая ссылка сообщения (`firstLink`); null — ссылки нет, карточки нет. */
url: string | null;
}
/** Непустое значение без внешних пробелов; пустая строка — как отсутствие. */
function nonEmpty(value: string | undefined): string | null {
const text = value?.trim() ?? '';
return text === '' ? null : text;
}
export function LinkPreviewCard({ url }: LinkPreviewCardProps) {
const { t } = useTranslation();
// Флаг инстанса: без него превью выключены и запрос к чужому сайту не уйдёт.
const meta = useQuery({
queryKey: metaQueryKey,
queryFn: ({ signal }) => fetchMeta(signal),
enabled: url !== null,
staleTime: 300_000,
retry: 0,
});
const unfurlEnabled = meta.data?.features?.unfurl_enabled === true;
const previewQuery = useQuery({
queryKey: linkPreviewQueryKey(url ?? ''),
queryFn: ({ signal }) => fetchLinkPreview(url ?? '', signal),
enabled: unfurlEnabled && url !== null,
// Ошибку показывать нечем: карточка — украшение, а не действие.
retry: 0,
// Успешное превью сервер кэширует на сутки, поэтому и в браузере держим
// карточку долго: повторный запрос уйдёт только после перезагрузки ленты.
staleTime: 600_000,
});
if (url === null || !unfurlEnabled) {
return null;
}
const response = previewQuery.data;
if (response === undefined || response.status !== 'ok' || response.preview === undefined) {
return null;
}
const preview = response.preview;
const title = nonEmpty(preview.title);
const description = nonEmpty(preview.description);
const siteName = nonEmpty(preview.site_name);
// Адрес картинки проверяет сервер, но данным из сети не доверяем: `data:` и
// `javascript:` в `src` не попадут.
const imageCandidate = nonEmpty(preview.image_url);
const image =
imageCandidate !== null && HTTP_URL_PATTERN.test(imageCandidate) ? imageCandidate : null;
if (title === null && description === null && image === null) {
return null;
}
// Ссылку берём из ответа (сервер мог её канонизировать), но если он вернул
// что-то не http(s) — ведём по исходному адресу из сообщения.
const href = HTTP_URL_PATTERN.test(preview.url) ? preview.url : url;
return (
<a
data-testid="link-preview"
href={href}
target="_blank"
rel="noopener noreferrer"
title={t('chat.linkPreview.open')}
className="flex max-w-xl flex-col overflow-hidden rounded-[var(--radius-md)] border border-border/60 bg-surface-2 transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 sm:flex-row"
>
{image === null ? null : (
<img
src={image}
alt={t('chat.linkPreview.image')}
loading="lazy"
referrerPolicy="no-referrer"
className="max-h-40 w-full shrink-0 border-b border-border/40 object-cover sm:h-24 sm:w-40 sm:border-b-0 sm:border-r"
/>
)}
<div className="flex min-w-0 flex-col gap-0.5 p-2">
{siteName === null ? null : (
<span className="truncate text-[10px] uppercase tracking-wide text-fg-muted">
{siteName}
</span>
)}
{title === null ? null : (
<span className="line-clamp-2 text-sm font-semibold text-fg">{title}</span>
)}
{description === null ? null : (
<span className="line-clamp-3 text-xs text-fg-muted">{description}</span>
)}
</div>
</a>
);
}
+9
View File
@@ -11,6 +11,7 @@ import { fileContentUrl } from '@/api/files';
import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages';
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { LinkPreviewCard } from '@/components/chat/LinkPreviewCard';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { useGuildEmojiDirectory } from '@/components/chat/useGuildEmojis';
@@ -19,6 +20,7 @@ import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { QUICK_REACTIONS, copyText, formatMessageDateTime, formatMessageTime } from '@/lib/chat';
import { customEmojiShortcode } from '@/lib/customEmoji';
import { formatBytes } from '@/lib/format';
import { firstLink } from '@/lib/links';
import { parseSystemMessage } from '@/lib/systemMessage';
import { useMessagesStore } from '@/stores/messages';
@@ -358,6 +360,11 @@ export function MessageItem({
);
}
// Карточка превью первой ссылки (Фаза 7). Флаг инстанса
// (`meta.features.unfurl_enabled`) и сам запрос проверяет `LinkPreviewCard`,
// поэтому без ссылки в тексте компонент даже не монтируется.
const previewUrl = firstLink(message.content);
return (
<article
data-message-id={message.id}
@@ -470,6 +477,8 @@ export function MessageItem({
</ul>
)}
{previewUrl === null ? null : <LinkPreviewCard url={previewUrl} />}
{message.reactions.length === 0 ? null : (
<ul className="flex flex-wrap gap-1" aria-label={t('chat.reactions.title')}>
{message.reactions.map((reaction) => {
+7 -2
View File
@@ -128,7 +128,8 @@
"push.disabled": "Push notifications are disabled on this instance.",
"push.invalid_endpoint": "The browser sent an invalid push service endpoint.",
"push.invalid_keys": "The browser sent invalid subscription keys.",
"push.too_many_subscriptions": "Too many devices with push notifications: disable some of them."
"push.too_many_subscriptions": "Too many devices with push notifications: disable some of them.",
"unfurl.disabled": "Link previews are disabled on this instance."
},
"theme": {
"switchToLight": "Light theme",
@@ -647,7 +648,11 @@
"memberLeave": "{{name}} left the server"
},
"webhookBadge": "webhook",
"webhookAuthor": "webhook"
"webhookAuthor": "webhook",
"linkPreview": {
"open": "Open link",
"image": "Preview image"
}
},
"user": {
"panelLabel": "User panel",
+7 -2
View File
@@ -128,7 +128,8 @@
"push.disabled": "Push-уведомления на инстансе выключены.",
"push.invalid_endpoint": "Браузер прислал некорректный адрес push-сервиса.",
"push.invalid_keys": "Браузер прислал некорректные ключи подписки.",
"push.too_many_subscriptions": "Слишком много устройств с push-уведомлениями: отключите лишние."
"push.too_many_subscriptions": "Слишком много устройств с push-уведомлениями: отключите лишние.",
"unfurl.disabled": "Превью ссылок на инстансе выключены."
},
"theme": {
"switchToLight": "Светлая тема",
@@ -647,7 +648,11 @@
"memberLeave": "{{name}} покинул сервер"
},
"webhookBadge": "вебхук",
"webhookAuthor": "вебхук"
"webhookAuthor": "вебхук",
"linkPreview": {
"open": "Открыть ссылку",
"image": "Картинка превью"
}
},
"user": {
"panelLabel": "Панель пользователя",
+98
View File
@@ -0,0 +1,98 @@
/**
* Первая ссылка в тексте сообщения (Фаза 7: серверные превью ссылок).
*
* Разбор опирается на `@/lib/markdown`: блоки кода ```…``` и инлайновый код
* `…` — это текст, а не адрес, поэтому ссылки оттуда не превьюшим (иначе
* `curl https://…` из примера тянул бы чужую страницу). Цитата `> …` ссылку
* сохраняет: префикс отбрасывается, как и при отрисовке ленты.
*
* Понимаются обе формы записи: markdown-ссылка `[текст](https://…)` и «голая»
* ссылка `https://…`. Из двух кандидатов берётся тот, что начинается раньше,
* поэтому в `[https://a](https://b)` победит адрес из скобок — ссылка ведёт
* туда, куда ведёт разметка, а не туда, где стоит похожий на адрес текст.
*
* Возвращается адрес ровно таким, каким он записан в сообщении: ничего не
* нормализуем и не перекодируем — канонизацией занимается сервер.
*/
import { parseBlocks, parseInline, QUOTE_PREFIX } from '@/lib/markdown';
/** Markdown-ссылка: адрес в скобках, хвостовая `)` в него не входит. */
const MARKDOWN_LINK_PATTERN = /\[[^\]\n]*\]\(\s*(https?:\/\/[^\s()<>`]+)\s*\)/gu;
/**
* «Голая» ссылка. Скобки, угловые скобки и обратная кавычка в адрес не входят:
* так `(https://…)` и `` `https://…` `` не съедают лишние символы. Набор
* символов тот же, что у ссылок в `@/lib/markdown`.
*/
const BARE_LINK_PATTERN = /https?:\/\/[^\s()<>`]+/gu;
/** Хвостовая пунктуация и маркеры разметки, которые не часть адреса. */
const TRAILING_CHARS = '.,!?;:)]}»"\'*_~';
/** Адрес без хоста (`https://`, `http://.`) ссылкой не считаем. */
const HOST_PATTERN = /^https?:\/\/[^\s./]/iu;
/** Адрес без хвостовой пунктуации и маркеров; пустая строка — не ссылка. */
function trimTrailing(raw: string): string {
let end = raw.length;
while (end > 0 && TRAILING_CHARS.includes(raw[end - 1] ?? '')) {
end -= 1;
}
const href = raw.slice(0, end);
return HOST_PATTERN.test(href) ? href : '';
}
/** Первая ссылка в одной строке; инлайновый код пропускаем. */
function firstLinkInLine(text: string): string | null {
// Инлайновый код начинается с обратной кавычки: без неё разбирать нечего,
// а `parseInline` на каждой строке ленты — лишняя работа.
const codeRanges = text.includes('`')
? parseInline(text)
.filter((token) => token.kind === 'code')
.map((token) => ({ start: token.start, end: token.end }))
: [];
const inCode = (index: number): boolean =>
codeRanges.some((range) => index >= range.start && index < range.end);
let bestStart = -1;
let bestHref: string | null = null;
const consider = (start: number, raw: string | undefined): void => {
if (raw === undefined || inCode(start)) {
return;
}
const href = trimTrailing(raw);
// Кандидат не раньше уже найденного — первый так и остаётся первым.
if (href === '' || (bestStart !== -1 && bestStart <= start)) {
return;
}
bestStart = start;
bestHref = href;
};
for (const match of text.matchAll(MARKDOWN_LINK_PATTERN)) {
consider(match.index ?? 0, match[1]);
}
for (const match of text.matchAll(BARE_LINK_PATTERN)) {
consider(match.index ?? 0, match[0]);
}
return bestHref;
}
/** Первая http(s)-ссылка в тексте сообщения или null. */
export function firstLink(content: string): string | null {
for (const block of parseBlocks(content)) {
// Блок кода — пример кода, а не ссылка: превью по нему не запрашиваем.
if (block.kind === 'code') {
continue;
}
for (const line of block.lines) {
const text = block.kind === 'quote' ? line.text.slice(QUOTE_PREFIX.length) : line.text;
const href = firstLinkInLine(text);
if (href !== null) {
return href;
}
}
}
return null;
}
+1
View File
@@ -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,
},
+284
View File
@@ -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);
});
});
+79
View File
@@ -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();
});
});
+1
View File
@@ -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'],
+1
View File
@@ -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,
},