Show preview images and link cards in Compose
This commit is contained in:
parent
d95817ff2e
commit
d677125555
12 changed files with 406 additions and 8 deletions
|
|
@ -115,6 +115,11 @@ async function backendRequest(method: string, params: Record<string, unknown>):
|
|||
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif'];
|
||||
|
||||
/** How long to wait for a link-preview page before giving up. */
|
||||
const LINK_PREVIEW_TIMEOUT_MS = 10_000;
|
||||
/** Cap on how much HTML we parse for meta tags. */
|
||||
const LINK_PREVIEW_MAX_BYTES = 1_000_000;
|
||||
|
||||
/** A best-effort MIME type derived from the file name. */
|
||||
function mimeForPath(filePath: string): string {
|
||||
switch (path.extname(filePath).toLowerCase()) {
|
||||
|
|
@ -187,6 +192,112 @@ async function uploadImage(filePath: string): Promise<{ url: string; mime: strin
|
|||
return { url: uploaded.url, mime: uploaded.mime ?? mime };
|
||||
}
|
||||
|
||||
interface LinkPreview {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
site_name: string | null;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/ /gi, ' ');
|
||||
}
|
||||
|
||||
/** Parse `<meta>` tags into a lower-cased property/name -> content map. */
|
||||
function parseMetaTags(html: string): Record<string, string> {
|
||||
const tags: Record<string, string> = {};
|
||||
const attr = (tag: string, name: string): string | null => {
|
||||
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i'));
|
||||
return match ? match[2] : null;
|
||||
};
|
||||
for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
|
||||
const key = attr(match[0], 'property') ?? attr(match[0], 'name');
|
||||
const value = attr(match[0], 'content');
|
||||
if (key && value && !(key.toLowerCase() in tags)) {
|
||||
tags[key.toLowerCase()] = decodeHtmlEntities(value.trim());
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Fetch a web page and pull an OpenGraph/Twitter card for the preview. */
|
||||
async function fetchLinkPreview(rawUrl: string): Promise<LinkPreview | null> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), LINK_PREVIEW_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) NostrFeedManager/0.1.0',
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().includes('text/html')) {
|
||||
return null;
|
||||
}
|
||||
const html = (await response.text()).slice(0, LINK_PREVIEW_MAX_BYTES);
|
||||
const meta = parseMetaTags(html);
|
||||
const base = new URL(response.url);
|
||||
|
||||
const resolveUrl = (value: string | undefined): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value, base).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const image = resolveUrl(meta['og:image'] ?? meta['twitter:image']);
|
||||
const fallbackTitle = extractTitle(html);
|
||||
const title =
|
||||
decodeHtmlEntities(meta['og:title'] ?? meta['twitter:title']) ||
|
||||
(fallbackTitle ? decodeHtmlEntities(fallbackTitle) : base.hostname);
|
||||
const description =
|
||||
decodeHtmlEntities(meta['og:description'] ?? meta['twitter:description'] ?? '') || null;
|
||||
const site_name = decodeHtmlEntities(meta['og:site_name'] ?? '') || null;
|
||||
|
||||
if (!image && !title) {
|
||||
return null;
|
||||
}
|
||||
return { url: base.toString(), title, description, image, site_name };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function extractTitle(html: string): string {
|
||||
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
return match ? decodeHtmlEntities(match[1].trim()) : '';
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
const window = new BrowserWindow({
|
||||
width: 1160,
|
||||
|
|
@ -236,11 +347,16 @@ app.whenReady().then(() => {
|
|||
'backend:request',
|
||||
async (_event, payload: { method: string; params?: Record<string, unknown> }) => {
|
||||
const params = payload.params ?? {};
|
||||
// Media tasks are handled here (Electron) rather than the Rust backend:
|
||||
// they need a native file dialog and the hosting upload.
|
||||
// Media and network tasks are handled here (Electron) rather than the
|
||||
// Rust backend: they need a native file dialog, the hosting upload, and
|
||||
// fetching pages for link previews.
|
||||
if (payload.method === 'pick_image') {
|
||||
return { status: 'ok', data: await pickImage() };
|
||||
}
|
||||
if (payload.method === 'link_preview') {
|
||||
const url = String(params.url ?? '');
|
||||
return { status: 'ok', data: url ? await fetchLinkPreview(url) : null };
|
||||
}
|
||||
if (payload.method === 'upload_image') {
|
||||
const filePath = String(params.path ?? '');
|
||||
if (!filePath) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: http://localhost:*; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: http://localhost:*; img-src 'self' data: https:; object-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
/>
|
||||
<title>Nostr Feed Manager</title>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
AppState,
|
||||
BackendResponse,
|
||||
LinkPreview,
|
||||
PickedImage,
|
||||
ProfileSummary,
|
||||
PublishReport,
|
||||
|
|
@ -68,5 +69,6 @@ export const api = {
|
|||
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
|
||||
pickImages: () => call<PickedImage[]>('pick_image'),
|
||||
uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }),
|
||||
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
|
||||
copyText: (text: string) => window.backend.copyText(text),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,3 +29,27 @@ export function extractImageUrls(content: string): string[] {
|
|||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web links in a note (URLs that are not direct image links), in first
|
||||
* appearance order and de-duplicated. These can be turned into link previews.
|
||||
*/
|
||||
export function extractLinkUrls(content: string): string[] {
|
||||
const urls: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const token of content.split(/\s+/)) {
|
||||
const url = token
|
||||
.trim()
|
||||
.replace(/[.,;:!?)\]}"']+$/g, '')
|
||||
.replace(/^[({['"]+/, '');
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
continue;
|
||||
}
|
||||
if (isImageLink(url) || seen.has(url)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
urls.push(url);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,15 @@ export interface UploadedImage {
|
|||
mime: string;
|
||||
}
|
||||
|
||||
/** OpenGraph/Twitter card details fetched for a link in a note. */
|
||||
export interface LinkPreview {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
site_name: string | null;
|
||||
}
|
||||
|
||||
/** Wire envelope returned by the Rust backend. */
|
||||
export type BackendResponse<T> =
|
||||
| { status: 'ok'; data: T }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert } from '../components/Alert';
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { Badge } from '../components/Badge';
|
||||
|
|
@ -7,7 +7,8 @@ import { CopyButton } from '../components/CopyButton';
|
|||
import { Icon } from '../components/Icon';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { shortenNpub } from '../lib/format';
|
||||
import { extractImageUrls } from '../lib/media';
|
||||
import { extractImageUrls, extractLinkUrls } from '../lib/media';
|
||||
import type { LinkPreview } from '../lib/types';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
const SOFT_LIMIT = 10_000;
|
||||
|
|
@ -32,8 +33,15 @@ function buildContent(content: string, attachments: Attachment[]): string {
|
|||
}
|
||||
|
||||
export function ComposeScreen() {
|
||||
const { state, publishNote, recordPublishFailure, lastPublish, pickImages, uploadImage } =
|
||||
useApp();
|
||||
const {
|
||||
state,
|
||||
publishNote,
|
||||
recordPublishFailure,
|
||||
lastPublish,
|
||||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
} = useApp();
|
||||
const [mode, setMode] = useState<'write' | 'preview'>('write');
|
||||
const [content, setContent] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
|
|
@ -41,6 +49,8 @@ export function ComposeScreen() {
|
|||
const [attachError, setAttachError] = useState<string | null>(null);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [linkPreviews, setLinkPreviews] = useState<Record<string, LinkPreview | null>>({});
|
||||
const [previewsLoading, setPreviewsLoading] = useState(false);
|
||||
|
||||
const active = state?.active_profile ?? null;
|
||||
const enabledCount = state?.settings.relays.filter((relay) => relay.enabled).length ?? 0;
|
||||
|
|
@ -49,6 +59,7 @@ export function ComposeScreen() {
|
|||
|
||||
const trimmed = content.trim();
|
||||
const contentImageUrls = useMemo(() => extractImageUrls(content), [content]);
|
||||
const linkUrls = useMemo(() => extractLinkUrls(content), [content]);
|
||||
const previewImages = useMemo(() => {
|
||||
const seen = new Set<string>(contentImageUrls);
|
||||
return [...seen, ...attachments.filter((a) => !seen.has(a.url)).map((a) => a.url)];
|
||||
|
|
@ -59,6 +70,34 @@ export function ComposeScreen() {
|
|||
enabledCount > 0 &&
|
||||
!publishing;
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'preview' || linkUrls.length === 0) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPreviewsLoading(true);
|
||||
(async () => {
|
||||
const results: Record<string, LinkPreview | null> = {};
|
||||
for (const url of linkUrls.slice(0, 3)) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
results[url] = await linkPreview(url);
|
||||
} catch {
|
||||
results[url] = null;
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setLinkPreviews((prev) => ({ ...prev, ...results }));
|
||||
setPreviewsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mode, linkUrls, linkPreview]);
|
||||
|
||||
const onAttach = async () => {
|
||||
setAttachError(null);
|
||||
setAttaching(true);
|
||||
|
|
@ -269,7 +308,7 @@ export function ComposeScreen() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="compose-preview-body">
|
||||
{trimmed.length === 0 && previewImages.length === 0 ? (
|
||||
{trimmed.length === 0 && previewImages.length === 0 && linkUrls.length === 0 ? (
|
||||
<p className="muted">Nothing to preview yet — write a note or attach an image.</p>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -287,6 +326,14 @@ export function ComposeScreen() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{linkUrls.slice(0, 3).map((url) => (
|
||||
<LinkPreviewCard
|
||||
key={url}
|
||||
url={url}
|
||||
preview={linkPreviews[url]}
|
||||
loading={previewsLoading && linkPreviews[url] === undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -368,3 +415,60 @@ export function ComposeScreen() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPreviewCard({
|
||||
url,
|
||||
preview,
|
||||
loading,
|
||||
}: {
|
||||
url: string;
|
||||
preview: LinkPreview | null | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="link-preview-card is-loading" data-testid="link-preview-loading">
|
||||
<span className="muted small">Fetching link preview…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview || (!preview.image && !preview.title)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = (() => {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<a
|
||||
className="link-preview-card"
|
||||
href={preview.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-testid="link-preview"
|
||||
>
|
||||
{preview.image && (
|
||||
<img
|
||||
src={preview.image}
|
||||
alt={preview.title}
|
||||
className="link-preview-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="link-preview-content">
|
||||
{preview.site_name && <span className="link-preview-site">{preview.site_name}</span>}
|
||||
<span className="link-preview-title">{preview.title}</span>
|
||||
{preview.description && (
|
||||
<span className="link-preview-description">{preview.description}</span>
|
||||
)}
|
||||
<span className="link-preview-url">{hostname}</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { api, BackendError } from '../lib/api';
|
||||
import type {
|
||||
AppState,
|
||||
LinkPreview,
|
||||
PickedImage,
|
||||
ProfileSummary,
|
||||
PublishReport,
|
||||
|
|
@ -53,6 +54,7 @@ interface AppContextValue {
|
|||
revealSecretKey: (npub: string) => Promise<RevealedKey>;
|
||||
pickImages: () => Promise<PickedImage[]>;
|
||||
uploadImage: (path: string) => Promise<UploadedImage>;
|
||||
linkPreview: (url: string) => Promise<LinkPreview | null>;
|
||||
copyText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +167,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
|
||||
const pickImages = useCallback(() => api.pickImages(), []);
|
||||
const uploadImage = useCallback((path: string) => api.uploadImage(path), []);
|
||||
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
|
||||
|
||||
const copyText = useCallback((text: string) => api.copyText(text), []);
|
||||
|
||||
|
|
@ -195,6 +198,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
revealSecretKey,
|
||||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
copyText,
|
||||
}),
|
||||
[
|
||||
|
|
@ -221,6 +225,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
revealSecretKey,
|
||||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
copyText,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1255,6 +1255,74 @@ select {
|
|||
object-fit: contain;
|
||||
}
|
||||
|
||||
.link-preview-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link-preview-card.is-loading {
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.link-preview-image {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.link-preview-site {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-preview-title {
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.link-preview-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.link-preview-url {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Relays
|
||||
------------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -224,4 +224,25 @@ describe('ComposeScreen', () => {
|
|||
(await screen.findAllByText('The image host rejected the upload (HTTP 413).')).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows a link preview card for a web link in the preview', async () => {
|
||||
const backend = createFakeBackend();
|
||||
const { user } = setup(backend);
|
||||
renderWithApp(<ComposeScreen />);
|
||||
|
||||
await screen.findByText('Alice');
|
||||
await user.type(screen.getByLabelText('Note content'), 'Read this https://example.com/article');
|
||||
await user.click(screen.getByRole('tab', { name: 'Preview' }));
|
||||
|
||||
const card = await screen.findByTestId('link-preview');
|
||||
expect(within(card).getByText('Example Article')).toBeInTheDocument();
|
||||
expect(within(card).getByText('example.com')).toBeInTheDocument();
|
||||
expect(within(card).getByRole('img', { name: 'Example Article' })).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/cover.jpg',
|
||||
);
|
||||
|
||||
const previewCall = backend.requests.find((r) => r.method === 'link_preview');
|
||||
expect(previewCall?.params.url).toBe('https://example.com/article');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export interface ApiMock {
|
|||
revealSecretKey: ReturnType<typeof vi.fn>;
|
||||
pickImages: ReturnType<typeof vi.fn>;
|
||||
uploadImage: ReturnType<typeof vi.fn>;
|
||||
linkPreview: ReturnType<typeof vi.fn>;
|
||||
copyText: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
/** Current state object backing init/getState. */
|
||||
|
|
@ -190,6 +191,13 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
|||
url: 'https://cdn.nostr.build/i/uploaded.png',
|
||||
mime: 'image/png',
|
||||
})),
|
||||
linkPreview: vi.fn(async () => ({
|
||||
url: 'https://example.com/article',
|
||||
title: 'Example Article',
|
||||
description: 'A short summary of the article.',
|
||||
image: 'https://example.com/cover.jpg',
|
||||
site_name: 'Example',
|
||||
})),
|
||||
copyText: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,15 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
return { url, mime: 'image/png' };
|
||||
}
|
||||
|
||||
case 'link_preview':
|
||||
return {
|
||||
url: String(params.url),
|
||||
title: 'Example Article',
|
||||
description: 'A short summary of the article.',
|
||||
image: 'https://example.com/cover.jpg',
|
||||
site_name: 'Example',
|
||||
};
|
||||
|
||||
case 'relay_add': {
|
||||
const url = String(params.url);
|
||||
const nextSettings: Settings = {
|
||||
|
|
|
|||
32
frontend/src/test/media.test.ts
Normal file
32
frontend/src/test/media.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { extractImageUrls, extractLinkUrls, isImageLink } from '../lib/media';
|
||||
|
||||
describe('media URL extraction', () => {
|
||||
it('recognises direct image links by extension', () => {
|
||||
expect(isImageLink('https://cdn.example.com/a.png')).toBe(true);
|
||||
expect(isImageLink('https://cdn.example.com/a.jpeg')).toBe(true);
|
||||
expect(isImageLink('https://cdn.example.com/a.PNG')).toBe(true);
|
||||
expect(isImageLink('https://cdn.example.com/a.webp?size=2')).toBe(true);
|
||||
expect(isImageLink('https://example.com/article')).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts image URLs in order and de-duplicates', () => {
|
||||
expect(
|
||||
extractImageUrls(
|
||||
'a https://cdn.example.com/one.png https://example.com/page b https://cdn.example.com/one.png',
|
||||
),
|
||||
).toEqual(['https://cdn.example.com/one.png']);
|
||||
});
|
||||
|
||||
it('extracts web links but not image URLs', () => {
|
||||
expect(
|
||||
extractLinkUrls('see https://example.com/article and https://cdn.example.com/pic.png'),
|
||||
).toEqual(['https://example.com/article']);
|
||||
});
|
||||
|
||||
it('strips surrounding punctuation from links', () => {
|
||||
expect(extractLinkUrls('(https://example.com/article).')).toEqual([
|
||||
'https://example.com/article',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue