From d67712555579d7de453cd6b227cfa9dde393eb74 Mon Sep 17 00:00:00 2001 From: Avi Date: Tue, 4 Aug 2026 13:53:12 -0500 Subject: [PATCH] Show preview images and link cards in Compose --- frontend/electron/main.ts | 120 ++++++++++++++++++++++- frontend/index.html | 2 +- frontend/src/lib/api.ts | 2 + frontend/src/lib/media.ts | 24 +++++ frontend/src/lib/types.ts | 9 ++ frontend/src/screens/ComposeScreen.tsx | 114 ++++++++++++++++++++- frontend/src/state/AppProvider.tsx | 5 + frontend/src/styles.css | 68 +++++++++++++ frontend/src/test/ComposeScreen.test.tsx | 21 ++++ frontend/src/test/apiMock.ts | 8 ++ frontend/src/test/fakeBackend.ts | 9 ++ frontend/src/test/media.test.ts | 32 ++++++ 12 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 frontend/src/test/media.test.ts diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index f4132a6..7cb0b3b 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -115,6 +115,11 @@ async function backendRequest(method: string, params: Record): 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 `` tags into a lower-cased property/name -> content map. */ +function parseMetaTags(html: string): Record { + const tags: Record = {}; + 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(/]*>/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 { + 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(/]*>([\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 }) => { 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) { diff --git a/frontend/index.html b/frontend/index.html index 5d1dbb7..9414212 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ Nostr Feed Manager diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b586357..401c824 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,7 @@ import type { AppState, BackendResponse, + LinkPreview, PickedImage, ProfileSummary, PublishReport, @@ -68,5 +69,6 @@ export const api = { revealSecretKey: (npub: string) => call('reveal_secret_key', { npub }), pickImages: () => call('pick_image'), uploadImage: (path: string) => call('upload_image', { path }), + linkPreview: (url: string) => call('link_preview', { url }), copyText: (text: string) => window.backend.copyText(text), }; diff --git a/frontend/src/lib/media.ts b/frontend/src/lib/media.ts index 5963a04..f970fb3 100644 --- a/frontend/src/lib/media.ts +++ b/frontend/src/lib/media.ts @@ -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(); + 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; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a66d9ca..bd535b4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 = | { status: 'ok'; data: T } diff --git a/frontend/src/screens/ComposeScreen.tsx b/frontend/src/screens/ComposeScreen.tsx index adf8f7c..ceae4a4 100644 --- a/frontend/src/screens/ComposeScreen.tsx +++ b/frontend/src/screens/ComposeScreen.tsx @@ -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([]); @@ -41,6 +49,8 @@ export function ComposeScreen() { const [attachError, setAttachError] = useState(null); const [publishing, setPublishing] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); + const [linkPreviews, setLinkPreviews] = useState>({}); + 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(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 = {}; + 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() {
- {trimmed.length === 0 && previewImages.length === 0 ? ( + {trimmed.length === 0 && previewImages.length === 0 && linkUrls.length === 0 ? (

Nothing to preview yet — write a note or attach an image.

) : ( <> @@ -287,6 +326,14 @@ export function ComposeScreen() { ))}
)} + {linkUrls.slice(0, 3).map((url) => ( + + ))} )} @@ -368,3 +415,60 @@ export function ComposeScreen() { ); } + +function LinkPreviewCard({ + url, + preview, + loading, +}: { + url: string; + preview: LinkPreview | null | undefined; + loading: boolean; +}) { + if (loading) { + return ( +
+ Fetching link preview… +
+ ); + } + + if (!preview || (!preview.image && !preview.title)) { + return null; + } + + const hostname = (() => { + try { + return new URL(url).hostname; + } catch { + return url; + } + })(); + + return ( + + {preview.image && ( + {preview.title} + )} +
+ {preview.site_name && {preview.site_name}} + {preview.title} + {preview.description && ( + {preview.description} + )} + {hostname} +
+
+ ); +} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 4fe6cca..724319f 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -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; pickImages: () => Promise; uploadImage: (path: string) => Promise; + linkPreview: (url: string) => Promise; copyText: (text: string) => Promise; } @@ -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, ], ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 95c97ef..af03727 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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 ------------------------------------------------------------------------- */ diff --git a/frontend/src/test/ComposeScreen.test.tsx b/frontend/src/test/ComposeScreen.test.tsx index 4fa1eb0..1d085e9 100644 --- a/frontend/src/test/ComposeScreen.test.tsx +++ b/frontend/src/test/ComposeScreen.test.tsx @@ -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(); + + 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'); + }); }); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index adcda26..b30f5a1 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -93,6 +93,7 @@ export interface ApiMock { revealSecretKey: ReturnType; pickImages: ReturnType; uploadImage: ReturnType; + linkPreview: ReturnType; copyText: ReturnType; }; /** 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), }; diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 7df1b2f..aab63b3 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -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 = { diff --git a/frontend/src/test/media.test.ts b/frontend/src/test/media.test.ts new file mode 100644 index 0000000..973fbbf --- /dev/null +++ b/frontend/src/test/media.test.ts @@ -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', + ]); + }); +});