diff --git a/web/src/api/meta.ts b/web/src/api/meta.ts index cc284f9..e94d426 100644 --- a/web/src/api/meta.ts +++ b/web/src/api/meta.ts @@ -7,6 +7,8 @@ export interface InstanceFeatures { web_push_enabled: boolean; oauth_enabled: boolean; passkeys_enabled: boolean; + /** Превью ссылок в сообщениях (Фаза 7): карточку готовит сервер. */ + unfurl_enabled: boolean; } export interface InstanceMeta { diff --git a/web/src/api/unfurl.ts b/web/src/api/unfurl.ts new file mode 100644 index 0000000..d20bbfb --- /dev/null +++ b/web/src/api/unfurl.ts @@ -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 | null { + return typeof value === 'object' && value !== null ? (value as Record) : 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 { + const payload = await request( + `/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 }; +} diff --git a/web/src/components/chat/LinkPreviewCard.tsx b/web/src/components/chat/LinkPreviewCard.tsx new file mode 100644 index 0000000..b250c00 --- /dev/null +++ b/web/src/components/chat/LinkPreviewCard.tsx @@ -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 ( + + {image === null ? null : ( + {t('chat.linkPreview.image')} + )} +
+ {siteName === null ? null : ( + + {siteName} + + )} + {title === null ? null : ( + {title} + )} + {description === null ? null : ( + {description} + )} +
+
+ ); +} diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index 9604709..10c8f53 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -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 (
)} + {previewUrl === null ? null : } + {message.reactions.length === 0 ? null : (
    {message.reactions.map((reaction) => { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ef95711..3e2cca8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 158665c..35ad564 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Панель пользователя", diff --git a/web/src/lib/links.ts b/web/src/lib/links.ts new file mode 100644 index 0000000..202d796 --- /dev/null +++ b/web/src/lib/links.ts @@ -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; +} diff --git a/web/tests/app.test.tsx b/web/tests/app.test.tsx index dbbfd60..947828c 100644 --- a/web/tests/app.test.tsx +++ b/web/tests/app.test.tsx @@ -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, }, diff --git a/web/tests/linkPreview.test.tsx b/web/tests/linkPreview.test.tsx new file mode 100644 index 0000000..e1a99f7 --- /dev/null +++ b/web/tests/linkPreview.test.tsx @@ -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( + + + , + ); + return { ...view, fetchMock, client }; +} + +/** Ждём, пока запрос превью завершится с нужным исходом. */ +async function waitForPreview( + client: QueryClient, + status: 'success' | 'error' = 'success', + target = url, +): Promise { + 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( + + undefined} + onLoadMore={() => undefined} + onReply={() => undefined} + /> + , + ); + 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); + }); +}); diff --git a/web/tests/links.test.ts b/web/tests/links.test.ts new file mode 100644 index 0000000..de5e706 --- /dev/null +++ b/web/tests/links.test.ts @@ -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(); + }); +}); diff --git a/web/tests/passkeys.test.tsx b/web/tests/passkeys.test.tsx index 2cb4c44..ee471b7 100644 --- a/web/tests/passkeys.test.tsx +++ b/web/tests/passkeys.test.tsx @@ -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'], diff --git a/web/tests/voice.test.tsx b/web/tests/voice.test.tsx index dbb44a8..c7915a5 100644 --- a/web/tests/voice.test.tsx +++ b/web/tests/voice.test.tsx @@ -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, },