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;
|
||||
}
|
||||
Reference in New Issue
Block a user