Add compose preview with image attachments (NIP-92 imeta)
This commit is contained in:
parent
17e8ace5ab
commit
3e3467b006
11 changed files with 710 additions and 57 deletions
|
|
@ -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 { readFileSync } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
|
@ -113,6 +113,68 @@ async function backendRequest(method: string, params: Record<string, unknown>):
|
|||
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 {
|
||||
const window = new BrowserWindow({
|
||||
width: 1160,
|
||||
|
|
@ -160,8 +222,34 @@ app.whenReady().then(() => {
|
|||
|
||||
ipcMain.handle(
|
||||
'backend:request',
|
||||
(_event, payload: { method: string; params?: Record<string, unknown> }) => {
|
||||
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.
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
31
frontend/src/lib/media.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,6 +135,28 @@ export function ComposeScreen() {
|
|||
{enabledCount === 0 && <Badge tone="warning">No relays enabled</Badge>}
|
||||
</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">
|
||||
<label htmlFor="note-content" className="visually-hidden">
|
||||
Note content
|
||||
|
|
@ -97,6 +171,37 @@ export function ComposeScreen() {
|
|||
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' : ''}`}
|
||||
|
|
@ -108,8 +213,20 @@ export function ComposeScreen() {
|
|||
<div className="compose-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setContent('')}
|
||||
disabled={content.length === 0 || publishing}
|
||||
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>
|
||||
|
|
@ -123,7 +240,7 @@ export function ComposeScreen() {
|
|||
? 'Select a profile before publishing'
|
||||
: enabledCount === 0
|
||||
? 'Enable a relay before publishing'
|
||||
: trimmed.length === 0
|
||||
: trimmed.length === 0 && attachments.length === 0
|
||||
? 'Write something to publish'
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -134,6 +251,47 @@ export function ComposeScreen() {
|
|||
</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)}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
------------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
126
src/publish.rs
126
src/publish.rs
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
|
@ -85,6 +86,69 @@ fn validate_content(content: &str) -> Result<(), AppError> {
|
|||
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(
|
||||
settings: &Settings,
|
||||
content: &str,
|
||||
|
|
@ -102,7 +166,7 @@ async fn publish_with_keys(
|
|||
|
||||
// Sign locally before touching the network so a signing failure is
|
||||
// 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
|
||||
.sign(keys)
|
||||
.await
|
||||
|
|
@ -349,4 +413,64 @@ mod tests {
|
|||
assert!(partial.succeeded_on_some());
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue