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