diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 1006b63..5f8df82 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -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): 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 }) => { + async (_event, payload: { method: string; params?: Record }) => { 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); }, ); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 79804f6..b586357 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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('lock_vault'), removeVaultPassword: (password: string) => call('remove_vault_password', { password }), revealSecretKey: (npub: string) => call('reveal_secret_key', { npub }), + pickImages: () => call('pick_image'), + uploadImage: (path: string) => call('upload_image', { path }), copyText: (text: string) => window.backend.copyText(text), }; diff --git a/frontend/src/lib/media.ts b/frontend/src/lib/media.ts new file mode 100644 index 0000000..5963a04 --- /dev/null +++ b/frontend/src/lib/media.ts @@ -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(); + 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; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0ba8b4a..a66d9ca 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 = | { status: 'ok'; data: T } diff --git a/frontend/src/screens/ComposeScreen.tsx b/frontend/src/screens/ComposeScreen.tsx index 1060cf8..adf8f7c 100644 --- a/frontend/src/screens/ComposeScreen.tsx +++ b/frontend/src/screens/ComposeScreen.tsx @@ -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([]); + const [attaching, setAttaching] = useState(false); + const [attachError, setAttachError] = useState(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(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 && No relays enabled} -
- -