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:
@@ -7,6 +7,8 @@ export interface InstanceFeatures {
|
||||
web_push_enabled: boolean;
|
||||
oauth_enabled: boolean;
|
||||
passkeys_enabled: boolean;
|
||||
/** Превью ссылок в сообщениях (Фаза 7): карточку готовит сервер. */
|
||||
unfurl_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface InstanceMeta {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Панель пользователя",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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