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'];
/** 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(/&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 {
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) {