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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue