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,11 +1,13 @@
import type {
AppState,
BackendResponse,
PickedImage,
ProfileSummary,
PublishReport,
RelayTestResult,
RevealedKey,
Settings,
UploadedImage,
} from './types';
declare global {
@ -64,5 +66,7 @@ export const api = {
lockVault: () => call<AppState>('lock_vault'),
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
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),
};

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;
}
/** 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. */
export type BackendResponse<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 { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge';
@ -7,13 +7,38 @@ 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 { useApp } from '../state/AppProvider';
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() {
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 [attachments, setAttachments] = useState<Attachment[]>([]);
const [attaching, setAttaching] = useState(false);
const [attachError, setAttachError] = useState<string | null>(null);
const [publishing, setPublishing] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
@ -23,12 +48,39 @@ export function ComposeScreen() {
const shorten = state?.settings.shorten_npub ?? true;
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 () => {
setPublishing(true);
try {
await publishNote(content);
await publishNote(buildContent(content, attachments));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const details =
@ -83,57 +135,163 @@ export function ComposeScreen() {
{enabledCount === 0 && <Badge tone="warning">No relays enabled</Badge>}
</div>
<div className="field">
<label htmlFor="note-content" className="visually-hidden">
Note content
</label>
<textarea
id="note-content"
className="note-editor"
rows={8}
placeholder="What's on your mind?"
value={content}
onChange={(event) => setContent(event.target.value)}
disabled={publishing}
maxLength={50_000}
/>
<div className="compose-footer">
<span
className={`char-count${content.length > SOFT_LIMIT ? ' is-warn' : ''}`}
aria-live="polite"
>
{content.length.toLocaleString()} characters
{content.length > SOFT_LIMIT && ' · long notes may be rejected by some relays'}
</span>
<div className="compose-actions">
<Button
variant="ghost"
onClick={() => setContent('')}
disabled={content.length === 0 || publishing}
<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">
<label htmlFor="note-content" className="visually-hidden">
Note content
</label>
<textarea
id="note-content"
className="note-editor"
rows={8}
placeholder="What's on your mind?"
value={content}
onChange={(event) => setContent(event.target.value)}
disabled={publishing}
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">
<span
className={`char-count${content.length > SOFT_LIMIT ? ' is-warn' : ''}`}
aria-live="polite"
>
Clear
</Button>
<Button
variant="primary"
onClick={onPublishClick}
loading={publishing}
disabled={!canPublish}
title={
!active
? 'Select a profile before publishing'
: enabledCount === 0
? 'Enable a relay before publishing'
: trimmed.length === 0
? 'Write something to publish'
: undefined
}
>
<Icon name="publish" size={18} />
{publishing ? 'Publishing…' : 'Publish'}
</Button>
{content.length.toLocaleString()} characters
{content.length > SOFT_LIMIT && ' · long notes may be rejected by some relays'}
</span>
<div className="compose-actions">
<Button
variant="ghost"
onClick={() => void onAttach()}
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
</Button>
<Button
variant="primary"
onClick={onPublishClick}
loading={publishing}
disabled={!canPublish}
title={
!active
? 'Select a profile before publishing'
: enabledCount === 0
? 'Enable a relay before publishing'
: trimmed.length === 0 && attachments.length === 0
? 'Write something to publish'
: undefined
}
>
<Icon name="publish" size={18} />
{publishing ? 'Publishing…' : 'Publish'}
</Button>
</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 className="card">
@ -184,8 +342,12 @@ export function ComposeScreen() {
<Modal open={confirmOpen} title="Confirm publication" onClose={() => setConfirmOpen(false)}>
<p>
Publish this note to {enabledCount} enabled relay{enabledCount === 1 ? '' : 's'}? Notes
on Nostr are public and cannot be edited.
Publish this note
{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>
<div className="modal-actions">
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>

View file

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

View file

@ -1140,6 +1140,121 @@ select {
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
------------------------------------------------------------------------- */

View file

@ -155,4 +155,73 @@ describe('ComposeScreen', () => {
await user.click(screen.getByRole('button', { name: 'Clear' }));
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>;
removeVaultPassword: ReturnType<typeof vi.fn>;
revealSecretKey: ReturnType<typeof vi.fn>;
pickImages: ReturnType<typeof vi.fn>;
uploadImage: ReturnType<typeof vi.fn>;
copyText: ReturnType<typeof vi.fn>;
};
/** 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),
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),
};

View file

@ -28,6 +28,12 @@ export interface FakeBackend {
relayErrors: Set<string>;
/** Per-method canned error override. */
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 {
@ -37,6 +43,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
const backend: FakeBackend = {
api: {
async request(method, params = {}) {
backend.requests.push({ method, params });
if (backend.nextErrors[method]) {
const { message, details, code } = backend.nextErrors[method];
return { status: 'error', message, details, code };
@ -75,6 +82,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
publishReport: makePublishReport(),
relayErrors: new Set(),
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> {
@ -130,6 +140,19 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
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': {
const url = String(params.url);
const nextSettings: Settings = {