Add compose preview with image attachments (NIP-92 imeta)

This commit is contained in:
Avi 2026-08-04 13:30:30 -05:00
commit 3e3467b006
11 changed files with 710 additions and 57 deletions

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, ipcMain, protocol } from 'electron'; import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol } from 'electron';
import { spawn, type ChildProcess } from 'node:child_process'; import { spawn, type ChildProcess } from 'node:child_process';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
@ -113,6 +113,68 @@ async function backendRequest(method: string, params: Record<string, unknown>):
return response; return response;
} }
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif'];
/** A best-effort MIME type derived from the file name. */
function mimeForPath(filePath: string): string {
switch (path.extname(filePath).toLowerCase()) {
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
case '.avif':
return 'image/avif';
default:
return 'application/octet-stream';
}
}
/** Show an open dialog and return the chosen image files (empty when cancelled). */
async function pickImage(): Promise<{ path: string; name: string; mime: string }[]> {
const result = await dialog.showOpenDialog({
title: 'Attach image(s)',
filters: [{ name: 'Images', extensions: IMAGE_EXTENSIONS }],
properties: ['openFile', 'multiSelections'],
});
if (result.canceled) {
return [];
}
return result.filePaths.map((filePath) => ({
path: filePath,
name: path.basename(filePath),
mime: mimeForPath(filePath),
}));
}
/** Upload one image file to nostr.build and return the public URL + MIME type. */
async function uploadImage(filePath: string): Promise<{ url: string; mime: string }> {
const data = readFileSync(filePath);
const mime = mimeForPath(filePath);
const form = new FormData();
form.append('fileToUpload', new Blob([data], { type: mime }), path.basename(filePath) || 'image');
const response = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
body: form,
});
const payload = (await response.json().catch(() => ({}))) as {
status?: string;
message?: string;
data?: { url?: string; mime?: string }[];
};
const uploaded = payload.data?.[0];
if (!response.ok || payload.status !== 'success' || !uploaded?.url) {
throw new Error(
payload.message || `The image host rejected the upload (HTTP ${response.status}).`,
);
}
return { url: uploaded.url, mime: uploaded.mime ?? mime };
}
function createWindow(): void { function createWindow(): void {
const window = new BrowserWindow({ const window = new BrowserWindow({
width: 1160, width: 1160,
@ -160,8 +222,34 @@ app.whenReady().then(() => {
ipcMain.handle( ipcMain.handle(
'backend:request', 'backend:request',
(_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:
// they need a native file dialog and the hosting upload.
if (payload.method === 'pick_image') {
return { status: 'ok', data: await pickImage() };
}
if (payload.method === 'upload_image') {
const filePath = String(params.path ?? '');
if (!filePath) {
return {
status: 'error',
code: 'bad_request',
message: 'No image file given.',
details: null,
};
}
try {
return { status: 'ok', data: await uploadImage(filePath) };
} catch (error) {
return {
status: 'error',
code: 'upload_failed',
message: error instanceof Error ? error.message : String(error),
details: null,
};
}
}
return backendRequest(payload.method, params); return backendRequest(payload.method, params);
}, },
); );

View file

@ -1,11 +1,13 @@
import type { import type {
AppState, AppState,
BackendResponse, BackendResponse,
PickedImage,
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
UploadedImage,
} from './types'; } from './types';
declare global { declare global {
@ -64,5 +66,7 @@ export const api = {
lockVault: () => call<AppState>('lock_vault'), lockVault: () => call<AppState>('lock_vault'),
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }), removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }), revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }),
copyText: (text: string) => window.backend.copyText(text), copyText: (text: string) => window.backend.copyText(text),
}; };

31
frontend/src/lib/media.ts Normal file
View file

@ -0,0 +1,31 @@
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
/** True when a URL/path points at a file with a known image extension. */
export function isImageLink(value: string): boolean {
const clean = value.split('?')[0].split('#')[0].toLowerCase();
return IMAGE_EXTENSIONS.some((ext) => clean.endsWith(`.${ext}`));
}
/**
* Image URLs found in a note, in first-appearance order and de-duplicated.
* Matches by extension only so the preview never has to fetch the link.
*/
export function extractImageUrls(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

@ -64,6 +64,24 @@ export interface RevealedKey {
nsec: string; nsec: string;
} }
/** An image file selected via the native file dialog. */
export interface PickedImage {
/** Absolute path on disk. */
path: string;
/** File name for display. */
name: string;
/** Best-effort MIME type from the file extension. */
mime: string;
}
/** An image uploaded to the hosting service, ready to publish. */
export interface UploadedImage {
/** Public URL of the uploaded file. */
url: string;
/** MIME type reported by the host. */
mime: string;
}
/** 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 { useState } from 'react'; import { 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,13 +7,38 @@ 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 { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
const SOFT_LIMIT = 10_000; const SOFT_LIMIT = 10_000;
interface Attachment {
url: string;
mime: string;
name: string;
}
/** Merge the draft text and attached image URLs into the published content. */
function buildContent(content: string, attachments: Attachment[]): string {
const parts: string[] = [];
const trimmed = content.trim();
if (trimmed) {
parts.push(trimmed);
}
for (const attachment of attachments) {
parts.push(attachment.url);
}
return parts.join('\n');
}
export function ComposeScreen() { export function ComposeScreen() {
const { state, publishNote, recordPublishFailure, lastPublish } = useApp(); const { state, publishNote, recordPublishFailure, lastPublish, pickImages, uploadImage } =
useApp();
const [mode, setMode] = useState<'write' | 'preview'>('write');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [attaching, setAttaching] = useState(false);
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);
@ -23,12 +48,39 @@ export function ComposeScreen() {
const shorten = state?.settings.shorten_npub ?? true; const shorten = state?.settings.shorten_npub ?? true;
const trimmed = content.trim(); const trimmed = content.trim();
const canPublish = trimmed.length > 0 && active !== null && enabledCount > 0 && !publishing; const contentImageUrls = useMemo(() => extractImageUrls(content), [content]);
const previewImages = useMemo(() => {
const seen = new Set<string>(contentImageUrls);
return [...seen, ...attachments.filter((a) => !seen.has(a.url)).map((a) => a.url)];
}, [contentImageUrls, attachments]);
const canPublish =
(trimmed.length > 0 || attachments.length > 0) &&
active !== null &&
enabledCount > 0 &&
!publishing;
const onAttach = async () => {
setAttachError(null);
setAttaching(true);
try {
const picked = await pickImages();
const uploaded: Attachment[] = [];
for (const image of picked) {
const result = await uploadImage(image.path);
uploaded.push({ url: result.url, mime: result.mime, name: image.name });
}
setAttachments((prev) => [...prev, ...uploaded]);
} catch (err) {
setAttachError(err instanceof Error ? err.message : String(err));
} finally {
setAttaching(false);
}
};
const doPublish = async () => { const doPublish = async () => {
setPublishing(true); setPublishing(true);
try { try {
await publishNote(content); await publishNote(buildContent(content, attachments));
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
const details = const details =
@ -83,6 +135,28 @@ export function ComposeScreen() {
{enabledCount === 0 && <Badge tone="warning">No relays enabled</Badge>} {enabledCount === 0 && <Badge tone="warning">No relays enabled</Badge>}
</div> </div>
<div className="compose-tabs" role="tablist" aria-label="Compose mode">
<button
type="button"
role="tab"
aria-selected={mode === 'write'}
className={`compose-tab${mode === 'write' ? ' is-active' : ''}`}
onClick={() => setMode('write')}
>
Write
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'preview'}
className={`compose-tab${mode === 'preview' ? ' is-active' : ''}`}
onClick={() => setMode('preview')}
>
Preview
</button>
</div>
{mode === 'write' ? (
<div className="field"> <div className="field">
<label htmlFor="note-content" className="visually-hidden"> <label htmlFor="note-content" className="visually-hidden">
Note content Note content
@ -97,6 +171,37 @@ export function ComposeScreen() {
disabled={publishing} disabled={publishing}
maxLength={50_000} maxLength={50_000}
/> />
{attachments.length > 0 && (
<div className="compose-attachments">
{attachments.map((attachment, index) => (
<div key={attachment.url} className="compose-attachment">
<img
src={attachment.url}
alt={attachment.name}
className="compose-attachment-thumb"
loading="lazy"
/>
<span className="compose-attachment-name" title={attachment.name}>
{attachment.name}
</span>
<button
type="button"
className="btn-icon"
aria-label={`Remove ${attachment.name}`}
onClick={() => setAttachments((prev) => prev.filter((_, i) => i !== index))}
disabled={publishing}
>
<Icon name="trash" size={16} />
</button>
</div>
))}
</div>
)}
{attachError && (
<Alert tone="error" title="Could not attach image" details={attachError}>
{attachError}
</Alert>
)}
<div className="compose-footer"> <div className="compose-footer">
<span <span
className={`char-count${content.length > SOFT_LIMIT ? ' is-warn' : ''}`} className={`char-count${content.length > SOFT_LIMIT ? ' is-warn' : ''}`}
@ -108,8 +213,20 @@ export function ComposeScreen() {
<div className="compose-actions"> <div className="compose-actions">
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setContent('')} onClick={() => void onAttach()}
disabled={content.length === 0 || publishing} loading={attaching}
disabled={publishing}
>
<Icon name="plus" size={16} />
Attach image
</Button>
<Button
variant="ghost"
onClick={() => {
setContent('');
setAttachments([]);
}}
disabled={(content.length === 0 && attachments.length === 0) || publishing}
> >
Clear Clear
</Button> </Button>
@ -123,7 +240,7 @@ export function ComposeScreen() {
? 'Select a profile before publishing' ? 'Select a profile before publishing'
: enabledCount === 0 : enabledCount === 0
? 'Enable a relay before publishing' ? 'Enable a relay before publishing'
: trimmed.length === 0 : trimmed.length === 0 && attachments.length === 0
? 'Write something to publish' ? 'Write something to publish'
: undefined : undefined
} }
@ -134,6 +251,47 @@ export function ComposeScreen() {
</div> </div>
</div> </div>
</div> </div>
) : (
<div className="compose-preview" data-testid="compose-preview">
<div className="compose-preview-head">
{active ? (
<Avatar npub={active.npub} label={active.label} />
) : (
<Badge tone="warning">No profile selected</Badge>
)}
<div>
<span className="profile-name">{active ? active.label : 'Your note'}</span>
{active && (
<code className="mono" title={active.npub}>
{shortenNpub(active.npub, shorten)}
</code>
)}
</div>
</div>
<div className="compose-preview-body">
{trimmed.length === 0 && previewImages.length === 0 ? (
<p className="muted">Nothing to preview yet write a note or attach an image.</p>
) : (
<>
{trimmed.length > 0 && <p className="note-text">{trimmed}</p>}
{previewImages.length > 0 && (
<div className="compose-preview-images">
{previewImages.map((url) => (
<img
key={url}
src={url}
alt={url}
className="compose-preview-img"
loading="lazy"
/>
))}
</div>
)}
</>
)}
</div>
</div>
)}
</section> </section>
<section className="card"> <section className="card">
@ -184,8 +342,12 @@ export function ComposeScreen() {
<Modal open={confirmOpen} title="Confirm publication" onClose={() => setConfirmOpen(false)}> <Modal open={confirmOpen} title="Confirm publication" onClose={() => setConfirmOpen(false)}>
<p> <p>
Publish this note to {enabledCount} enabled relay{enabledCount === 1 ? '' : 's'}? Notes Publish this note
on Nostr are public and cannot be edited. {attachments.length > 0
? ` with ${attachments.length} image` + (attachments.length === 1 ? '' : 's')
: ''}{' '}
to {enabledCount} enabled relay{enabledCount === 1 ? '' : 's'}? Notes on Nostr are
public and cannot be edited.
</p> </p>
<div className="modal-actions"> <div className="modal-actions">
<Button variant="ghost" onClick={() => setConfirmOpen(false)}> <Button variant="ghost" onClick={() => setConfirmOpen(false)}>

View file

@ -10,12 +10,14 @@ import {
import { api, BackendError } from '../lib/api'; import { api, BackendError } from '../lib/api';
import type { import type {
AppState, AppState,
PickedImage,
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
Theme, Theme,
UploadedImage,
} from '../lib/types'; } from '../lib/types';
export interface LastPublish { export interface LastPublish {
@ -49,6 +51,8 @@ interface AppContextValue {
lockVault: () => Promise<AppState>; lockVault: () => Promise<AppState>;
removeVaultPassword: (password: string) => Promise<AppState>; removeVaultPassword: (password: string) => Promise<AppState>;
revealSecretKey: (npub: string) => Promise<RevealedKey>; revealSecretKey: (npub: string) => Promise<RevealedKey>;
pickImages: () => Promise<PickedImage[]>;
uploadImage: (path: string) => Promise<UploadedImage>;
copyText: (text: string) => Promise<void>; copyText: (text: string) => Promise<void>;
} }
@ -159,6 +163,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
[applyState], [applyState],
); );
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []); const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
const pickImages = useCallback(() => api.pickImages(), []);
const uploadImage = useCallback((path: string) => api.uploadImage(path), []);
const copyText = useCallback((text: string) => api.copyText(text), []); const copyText = useCallback((text: string) => api.copyText(text), []);
@ -187,6 +193,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
lockVault, lockVault,
removeVaultPassword, removeVaultPassword,
revealSecretKey, revealSecretKey,
pickImages,
uploadImage,
copyText, copyText,
}), }),
[ [
@ -211,6 +219,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
lockVault, lockVault,
removeVaultPassword, removeVaultPassword,
revealSecretKey, revealSecretKey,
pickImages,
uploadImage,
copyText, copyText,
], ],
); );

View file

@ -1140,6 +1140,121 @@ select {
gap: 10px; gap: 10px;
} }
.compose-tabs {
display: flex;
gap: 4px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 14px;
}
.compose-tab {
font: inherit;
font-size: 14px;
font-weight: 500;
padding: 7px 16px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.compose-tab:hover {
color: var(--text);
border-color: var(--text-muted);
}
.compose-tab.is-active {
color: var(--primary);
border-color: var(--primary);
background: var(--primary-soft);
}
.compose-attachments {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.compose-attachment {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 6px 6px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.compose-attachment-thumb {
width: 44px;
height: 44px;
border-radius: 8px;
object-fit: cover;
}
.compose-attachment-name {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--text-muted);
}
.compose-preview {
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
min-height: 160px;
}
.compose-preview-head {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.compose-preview-head > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.compose-preview-head .mono {
color: var(--text-muted);
font-size: 13px;
}
.compose-preview-body {
margin-top: 12px;
}
.compose-preview .note-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
}
.compose-preview-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.compose-preview-img {
max-width: 100%;
max-height: 320px;
border-radius: 10px;
object-fit: contain;
}
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
Relays Relays
------------------------------------------------------------------------- */ ------------------------------------------------------------------------- */

View file

@ -155,4 +155,73 @@ describe('ComposeScreen', () => {
await user.click(screen.getByRole('button', { name: 'Clear' })); await user.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByLabelText('Note content')).toHaveValue(''); expect(screen.getByLabelText('Note content')).toHaveValue('');
}); });
it('shows a preview of the draft', async () => {
const backend = createFakeBackend();
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
await user.type(screen.getByLabelText('Note content'), 'Hello world');
await user.click(screen.getByRole('tab', { name: 'Preview' }));
const preview = screen.getByTestId('compose-preview');
expect(within(preview).getByText('Hello world')).toBeInTheDocument();
});
it('renders image URLs from the draft as thumbnails in the preview', async () => {
const backend = createFakeBackend();
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
await user.type(
screen.getByLabelText('Note content'),
'Check https://cdn.nostr.build/i/photo.png',
);
await user.click(screen.getByRole('tab', { name: 'Preview' }));
const preview = screen.getByTestId('compose-preview');
expect(within(preview).getAllByRole('img')).toHaveLength(1);
expect(within(preview).getAllByRole('img')[0]).toHaveAttribute(
'src',
'https://cdn.nostr.build/i/photo.png',
);
});
it('attaches an image and appends its URL when publishing', async () => {
const backend = createFakeBackend();
backend.state.settings.confirm_before_publish = false;
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
await user.type(screen.getByLabelText('Note content'), 'Hello');
await user.click(screen.getByRole('button', { name: /Attach image/i }));
const url = backend.uploadUrls[0];
expect(await screen.findByText('picked.png')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'picked.png' })).toHaveAttribute('src', url);
await user.click(publishButton());
const publishCall = backend.requests.find((r) => r.method === 'publish_note');
expect(publishCall?.params.content).toBe(`Hello\n${url}`);
});
it('shows an error when the image host rejects the upload', async () => {
const backend = createFakeBackend();
backend.nextErrors.upload_image = {
message: 'The image host rejected the upload (HTTP 413).',
code: 'upload_failed',
};
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
await user.click(screen.getByRole('button', { name: /Attach image/i }));
expect(
(await screen.findAllByText('The image host rejected the upload (HTTP 413).')).length,
).toBeGreaterThan(0);
});
}); });

View file

@ -91,6 +91,8 @@ export interface ApiMock {
lockVault: ReturnType<typeof vi.fn>; lockVault: ReturnType<typeof vi.fn>;
removeVaultPassword: ReturnType<typeof vi.fn>; removeVaultPassword: ReturnType<typeof vi.fn>;
revealSecretKey: ReturnType<typeof vi.fn>; revealSecretKey: ReturnType<typeof vi.fn>;
pickImages: ReturnType<typeof vi.fn>;
uploadImage: 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. */
@ -181,6 +183,13 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64), hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
nsec: `nsec1${npub.slice(5)}`, nsec: `nsec1${npub.slice(5)}`,
})), })),
pickImages: vi.fn(async () => [
{ path: '/tmp/picked.png', name: 'picked.png', mime: 'image/png' },
]),
uploadImage: vi.fn(async () => ({
url: 'https://cdn.nostr.build/i/uploaded.png',
mime: 'image/png',
})),
copyText: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined),
}; };

View file

@ -28,6 +28,12 @@ export interface FakeBackend {
relayErrors: Set<string>; relayErrors: Set<string>;
/** Per-method canned error override. */ /** Per-method canned error override. */
nextErrors: Record<string, { message: string; details?: string; code?: string }>; nextErrors: Record<string, { message: string; details?: string; code?: string }>;
/** Every request dispatched through `api.request`, in order. */
requests: { method: string; params: Record<string, unknown> }[];
/** Files returned by `pick_image`. */
pickedImages: { path: string; name: string; mime: string }[];
/** URLs returned by `upload_image`, one per call. */
uploadUrls: string[];
} }
export function createFakeBackend(initial?: AppState): FakeBackend { export function createFakeBackend(initial?: AppState): FakeBackend {
@ -37,6 +43,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
const backend: FakeBackend = { const backend: FakeBackend = {
api: { api: {
async request(method, params = {}) { async request(method, params = {}) {
backend.requests.push({ method, params });
if (backend.nextErrors[method]) { if (backend.nextErrors[method]) {
const { message, details, code } = backend.nextErrors[method]; const { message, details, code } = backend.nextErrors[method];
return { status: 'error', message, details, code }; return { status: 'error', message, details, code };
@ -75,6 +82,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
publishReport: makePublishReport(), publishReport: makePublishReport(),
relayErrors: new Set(), relayErrors: new Set(),
nextErrors: {}, nextErrors: {},
requests: [],
pickedImages: [{ path: '/tmp/picked.png', name: 'picked.png', mime: 'image/png' }],
uploadUrls: ['https://cdn.nostr.build/i/uploaded.png'],
}; };
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> { async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
@ -130,6 +140,19 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return report; return report;
} }
case 'pick_image':
return [...backend.pickedImages];
case 'upload_image': {
const index = backend.requests.filter((r) => r.method === 'upload_image').length - 1;
const url =
backend.uploadUrls[index] ?? backend.uploadUrls[backend.uploadUrls.length - 1] ?? '';
if (!url) {
throw new Error('The image host did not return a URL.');
}
return { url, mime: 'image/png' };
}
case 'relay_add': { case 'relay_add': {
const url = String(params.url); const url = String(params.url);
const nextSettings: Settings = { const nextSettings: Settings = {

View file

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@ -85,6 +86,69 @@ fn validate_content(content: &str) -> Result<(), AppError> {
Ok(()) Ok(())
} }
/// The MIME type for a URL whose path ends in a known image extension.
fn image_mime_from_url(url: &str) -> Option<&'static str> {
let path = url.split(['?', '#']).next().unwrap_or(url);
let extension = path.rsplit('.').next()?.to_ascii_lowercase();
match extension.as_str() {
"jpg" | "jpeg" => Some("image/jpeg"),
"png" => Some("image/png"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
"avif" => Some("image/avif"),
_ => None,
}
}
/// Image URLs found in a note, in first-appearance order and de-duplicated.
///
/// Links are matched by extension rather than content so we never have to
/// download anything just to decide whether to tag it.
fn extract_image_urls(content: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut urls = Vec::new();
for token in content.split_whitespace() {
let url = token.trim_matches(|c: char| {
matches!(
c,
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '(' | '[' | '{' | '\''
)
});
if !(url.starts_with("http://") || url.starts_with("https://")) {
continue;
}
if image_mime_from_url(url).is_none() {
continue;
}
if seen.insert(url.to_string()) {
urls.push(url.to_string());
}
}
urls
}
/// Media tags for every image URL in the note.
///
/// Returns NIP-92 `imeta` tags (the modern form) plus the legacy `image` tag
/// so both new and older clients render the pictures.
fn image_tags(content: &str) -> Vec<Tag> {
let mut tags = Vec::new();
for url in extract_image_urls(content) {
let mime = image_mime_from_url(&url);
let mut imeta = vec!["imeta".to_string(), format!("url {url}")];
if let Some(mime) = mime {
imeta.push(format!("m {mime}"));
}
if let Ok(tag) = Tag::parse(imeta) {
tags.push(tag);
}
if let Ok(tag) = Tag::parse(["image", url.as_str()]) {
tags.push(tag);
}
}
tags
}
async fn publish_with_keys( async fn publish_with_keys(
settings: &Settings, settings: &Settings,
content: &str, content: &str,
@ -102,7 +166,7 @@ async fn publish_with_keys(
// Sign locally before touching the network so a signing failure is // Sign locally before touching the network so a signing failure is
// reported as such rather than as a network error. // reported as such rather than as a network error.
let builder = EventBuilder::new(Kind::TextNote, content.to_string()); let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
let event = builder let event = builder
.sign(keys) .sign(keys)
.await .await
@ -349,4 +413,64 @@ mod tests {
assert!(partial.succeeded_on_some()); assert!(partial.succeeded_on_some());
assert!(!partial.succeeded_on_all(2)); assert!(!partial.succeeded_on_all(2));
} }
#[test]
fn extracts_image_urls_in_order_and_deduplicates() {
let content = "look https://cdn.example.com/a.png and https://cdn.example.com/b.JPG \
again https://cdn.example.com/a.png";
let urls = extract_image_urls(content);
assert_eq!(
urls,
vec![
"https://cdn.example.com/a.png".to_string(),
"https://cdn.example.com/b.JPG".to_string()
]
);
}
#[test]
fn ignores_non_image_urls_and_punctuation() {
let content = "see https://example.com/page. and https://example.com/x.png?size=2, \
(https://example.com/y.webp)";
assert_eq!(
extract_image_urls(content),
vec![
"https://example.com/x.png?size=2".to_string(),
"https://example.com/y.webp".to_string()
]
);
}
#[test]
fn no_image_urls_yields_no_tags() {
assert!(image_tags("just a text note https://example.com/landing").is_empty());
}
#[test]
fn builds_imeta_and_image_tags_for_picture_urls() {
let tags = image_tags("pic https://cdn.example.com/pic.png");
assert_eq!(tags.len(), 2);
assert_eq!(
tags[0].as_slice(),
&[
"imeta",
"url https://cdn.example.com/pic.png",
"m image/png"
]
);
assert_eq!(
tags[1].as_slice(),
&["image", "https://cdn.example.com/pic.png"]
);
}
#[test]
fn mime_falls_back_to_none_for_unknown_extensions() {
assert_eq!(image_mime_from_url("https://example.com/photo"), None);
assert_eq!(image_mime_from_url("https://example.com/photo.pdf"), None);
assert_eq!(
image_mime_from_url("https://example.com/photo.gif?size=1"),
Some("image/gif")
);
}
} }