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 { 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);
},
);