Show preview images and link cards in Compose

This commit is contained in:
Avi 2026-08-04 13:53:12 -05:00
commit d677125555
12 changed files with 406 additions and 8 deletions

View file

@ -115,6 +115,11 @@ async function backendRequest(method: string, params: Record<string, unknown>):
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']; 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. */ /** A best-effort MIME type derived from the file name. */
function mimeForPath(filePath: string): string { function mimeForPath(filePath: string): string {
switch (path.extname(filePath).toLowerCase()) { 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 }; 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(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&nbsp;/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 { function createWindow(): void {
const window = new BrowserWindow({ const window = new BrowserWindow({
width: 1160, width: 1160,
@ -236,11 +347,16 @@ app.whenReady().then(() => {
'backend:request', 'backend:request',
async (_event, payload: { method: string; params?: Record<string, unknown> }) => { async (_event, payload: { method: string; params?: Record<string, unknown> }) => {
const params = payload.params ?? {}; const params = payload.params ?? {};
// Media tasks are handled here (Electron) rather than the Rust backend: // Media and network tasks are handled here (Electron) rather than the
// they need a native file dialog and the hosting upload. // Rust backend: they need a native file dialog, the hosting upload, and
// fetching pages for link previews.
if (payload.method === 'pick_image') { if (payload.method === 'pick_image') {
return { status: 'ok', data: await pickImage() }; 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') { if (payload.method === 'upload_image') {
const filePath = String(params.path ?? ''); const filePath = String(params.path ?? '');
if (!filePath) { if (!filePath) {

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" 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> <title>Nostr Feed Manager</title>
</head> </head>

View file

@ -1,6 +1,7 @@
import type { import type {
AppState, AppState,
BackendResponse, BackendResponse,
LinkPreview,
PickedImage, PickedImage,
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
@ -68,5 +69,6 @@ export const api = {
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }), revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
pickImages: () => call<PickedImage[]>('pick_image'), pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }), uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }),
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
copyText: (text: string) => window.backend.copyText(text), copyText: (text: string) => window.backend.copyText(text),
}; };

View file

@ -29,3 +29,27 @@ export function extractImageUrls(content: string): string[] {
} }
return urls; 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;
}

View file

@ -82,6 +82,15 @@ export interface UploadedImage {
mime: string; 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. */ /** Wire envelope returned by the Rust backend. */
export type BackendResponse<T> = export type BackendResponse<T> =
| { status: 'ok'; data: T } | { status: 'ok'; data: T }

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Alert } from '../components/Alert'; import { Alert } from '../components/Alert';
import { Avatar } from '../components/Avatar'; import { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
@ -7,7 +7,8 @@ import { CopyButton } from '../components/CopyButton';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
import { shortenNpub } from '../lib/format'; 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'; import { useApp } from '../state/AppProvider';
const SOFT_LIMIT = 10_000; const SOFT_LIMIT = 10_000;
@ -32,8 +33,15 @@ function buildContent(content: string, attachments: Attachment[]): string {
} }
export function ComposeScreen() { export function ComposeScreen() {
const { state, publishNote, recordPublishFailure, lastPublish, pickImages, uploadImage } = const {
useApp(); state,
publishNote,
recordPublishFailure,
lastPublish,
pickImages,
uploadImage,
linkPreview,
} = useApp();
const [mode, setMode] = useState<'write' | 'preview'>('write'); const [mode, setMode] = useState<'write' | 'preview'>('write');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
@ -41,6 +49,8 @@ export function ComposeScreen() {
const [attachError, setAttachError] = useState<string | null>(null); const [attachError, setAttachError] = useState<string | null>(null);
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [confirmOpen, setConfirmOpen] = 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 active = state?.active_profile ?? null;
const enabledCount = state?.settings.relays.filter((relay) => relay.enabled).length ?? 0; const enabledCount = state?.settings.relays.filter((relay) => relay.enabled).length ?? 0;
@ -49,6 +59,7 @@ export function ComposeScreen() {
const trimmed = content.trim(); const trimmed = content.trim();
const contentImageUrls = useMemo(() => extractImageUrls(content), [content]); const contentImageUrls = useMemo(() => extractImageUrls(content), [content]);
const linkUrls = useMemo(() => extractLinkUrls(content), [content]);
const previewImages = useMemo(() => { const previewImages = useMemo(() => {
const seen = new Set<string>(contentImageUrls); const seen = new Set<string>(contentImageUrls);
return [...seen, ...attachments.filter((a) => !seen.has(a.url)).map((a) => a.url)]; return [...seen, ...attachments.filter((a) => !seen.has(a.url)).map((a) => a.url)];
@ -59,6 +70,34 @@ export function ComposeScreen() {
enabledCount > 0 && enabledCount > 0 &&
!publishing; !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 () => { const onAttach = async () => {
setAttachError(null); setAttachError(null);
setAttaching(true); setAttaching(true);
@ -269,7 +308,7 @@ export function ComposeScreen() {
</div> </div>
</div> </div>
<div className="compose-preview-body"> <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> <p className="muted">Nothing to preview yet write a note or attach an image.</p>
) : ( ) : (
<> <>
@ -287,6 +326,14 @@ export function ComposeScreen() {
))} ))}
</div> </div>
)} )}
{linkUrls.slice(0, 3).map((url) => (
<LinkPreviewCard
key={url}
url={url}
preview={linkPreviews[url]}
loading={previewsLoading && linkPreviews[url] === undefined}
/>
))}
</> </>
)} )}
</div> </div>
@ -368,3 +415,60 @@ export function ComposeScreen() {
</div> </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>
);
}

View file

@ -10,6 +10,7 @@ import {
import { api, BackendError } from '../lib/api'; import { api, BackendError } from '../lib/api';
import type { import type {
AppState, AppState,
LinkPreview,
PickedImage, PickedImage,
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
@ -53,6 +54,7 @@ interface AppContextValue {
revealSecretKey: (npub: string) => Promise<RevealedKey>; revealSecretKey: (npub: string) => Promise<RevealedKey>;
pickImages: () => Promise<PickedImage[]>; pickImages: () => Promise<PickedImage[]>;
uploadImage: (path: string) => Promise<UploadedImage>; uploadImage: (path: string) => Promise<UploadedImage>;
linkPreview: (url: string) => Promise<LinkPreview | null>;
copyText: (text: string) => Promise<void>; copyText: (text: string) => Promise<void>;
} }
@ -165,6 +167,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []); const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
const pickImages = useCallback(() => api.pickImages(), []); const pickImages = useCallback(() => api.pickImages(), []);
const uploadImage = useCallback((path: string) => api.uploadImage(path), []); const uploadImage = useCallback((path: string) => api.uploadImage(path), []);
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
const copyText = useCallback((text: string) => api.copyText(text), []); const copyText = useCallback((text: string) => api.copyText(text), []);
@ -195,6 +198,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
revealSecretKey, revealSecretKey,
pickImages, pickImages,
uploadImage, uploadImage,
linkPreview,
copyText, copyText,
}), }),
[ [
@ -221,6 +225,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
revealSecretKey, revealSecretKey,
pickImages, pickImages,
uploadImage, uploadImage,
linkPreview,
copyText, copyText,
], ],
); );

View file

@ -1255,6 +1255,74 @@ select {
object-fit: contain; 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 Relays
------------------------------------------------------------------------- */ ------------------------------------------------------------------------- */

View file

@ -224,4 +224,25 @@ describe('ComposeScreen', () => {
(await screen.findAllByText('The image host rejected the upload (HTTP 413).')).length, (await screen.findAllByText('The image host rejected the upload (HTTP 413).')).length,
).toBeGreaterThan(0); ).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');
});
}); });

View file

@ -93,6 +93,7 @@ export interface ApiMock {
revealSecretKey: ReturnType<typeof vi.fn>; revealSecretKey: ReturnType<typeof vi.fn>;
pickImages: ReturnType<typeof vi.fn>; pickImages: ReturnType<typeof vi.fn>;
uploadImage: ReturnType<typeof vi.fn>; uploadImage: ReturnType<typeof vi.fn>;
linkPreview: ReturnType<typeof vi.fn>;
copyText: ReturnType<typeof vi.fn>; copyText: ReturnType<typeof vi.fn>;
}; };
/** Current state object backing init/getState. */ /** 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', url: 'https://cdn.nostr.build/i/uploaded.png',
mime: 'image/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), copyText: vi.fn(async () => undefined),
}; };

View file

@ -153,6 +153,15 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return { url, mime: 'image/png' }; 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': { case 'relay_add': {
const url = String(params.url); const url = String(params.url);
const nextSettings: Settings = { const nextSettings: Settings = {

View 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',
]);
});
});