diff --git a/android/app/build.gradle b/android/app/build.gradle index 564f6c30..b83418a2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -42,6 +42,8 @@ dependencies { androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" implementation "org.apache.cordova:framework:$cordovaAndroidVersion" + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.okio:okio:3.9.0' } apply from: 'capacitor.build.gradle' diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json index 8ceced6f..514dff54 100644 --- a/android/app/src/main/assets/capacitor.config.json +++ b/android/app/src/main/assets/capacitor.config.json @@ -6,6 +6,12 @@ "plugins": { "CapacitorHttp": { "enabled": true + }, + "FileUploader": { + "enabled": true } + }, + "server": { + "androidScheme": "https" } } diff --git a/android/app/src/main/java/seedit/android/FileUploaderPlugin.java b/android/app/src/main/java/seedit/android/FileUploaderPlugin.java new file mode 100644 index 00000000..7aa40efb --- /dev/null +++ b/android/app/src/main/java/seedit/android/FileUploaderPlugin.java @@ -0,0 +1,268 @@ +package seedit.android; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.provider.MediaStore; +import android.util.Log; +import android.database.Cursor; +import android.provider.OpenableColumns; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.ActivityCallback; +import com.getcapacitor.PluginCall; +import androidx.activity.result.ActivityResult; + +import java.io.File; +import java.io.IOException; +import java.io.FileOutputStream; +import java.util.concurrent.TimeUnit; +import java.io.InputStream; +import java.io.ByteArrayOutputStream; + +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +@CapacitorPlugin(name = "FileUploader") +public class FileUploaderPlugin extends Plugin { + private static final String TAG = "FileUploaderPlugin"; + + @PluginMethod + public void pickAndUploadMedia(PluginCall call) { + Log.d(TAG, "pickAndUploadMedia called"); + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + String[] mimeTypes = {"image/jpeg", "image/png", "video/mp4", "video/webm"}; + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + startActivityForResult(call, intent, "pickFileResult"); + } + + @ActivityCallback + private void pickFileResult(PluginCall call, ActivityResult result) { + Log.d(TAG, "pickFileResult callback received"); + if (call == null) { + return; + } + + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null) { + Uri uri = data.getData(); + uploadToCatbox(uri, call); + } else { + call.reject("No data received"); + } + } else { + call.reject("File selection cancelled"); + } + } + + private void uploadToCatbox(Uri fileUri, PluginCall call) { + new Thread(() -> { + try { + Log.d(TAG, "Starting file conversion from URI"); + File file = FileUtils.getFileFromUri(getContext(), fileUri); + Log.d(TAG, "File name: " + file.getName()); + + JSObject statusUpdate = new JSObject(); + statusUpdate.put("status", "Uploading to catbox.moe..."); + notifyListeners("uploadStatus", statusUpdate); + + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build(); + + RequestBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("reqtype", "fileupload") + .addFormDataPart("fileToUpload", file.getName(), + RequestBody.create(MediaType.parse("application/octet-stream"), file)) + .build(); + + Request request = new Request.Builder() + .url("https://catbox.moe/user/api.php") + .post(requestBody) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) throw new IOException("Unexpected response " + response); + + String url = response.body().string(); + Log.d(TAG, "Upload successful. URL: " + url); + + JSObject ret = new JSObject(); + ret.put("url", url); + ret.put("fileName", file.getName()); + ret.put("status", "Upload complete!"); + call.resolve(ret); + } + } catch (Exception e) { + Log.e(TAG, "Upload failed", e); + call.reject("Upload failed: " + e.getMessage()); + } + }).start(); + } + + @PluginMethod + public void uploadMedia(PluginCall call) { + if (!call.getData().has("fileData") || !call.getData().has("fileName")) { + call.reject("Missing required parameters fileData or fileName"); + return; + } + + String fileData = call.getString("fileData"); + String fileName = call.getString("fileName"); + + // Execute in background thread to avoid blocking UI + new Thread(() -> { + try { + // Convert base64 to file + byte[] decodedBytes = android.util.Base64.decode(fileData, android.util.Base64.DEFAULT); + File tempFile = new File(getContext().getCacheDir(), fileName); + try (FileOutputStream fos = new FileOutputStream(tempFile)) { + fos.write(decodedBytes); + } + + // Create multipart upload form + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build(); + + RequestBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("reqtype", "fileupload") + .addFormDataPart("fileToUpload", fileName, + RequestBody.create(MediaType.parse("application/octet-stream"), tempFile)) + .build(); + + Request request = new Request.Builder() + .url("https://catbox.moe/user/api.php") + .post(requestBody) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) throw new IOException("Unexpected response " + response); + + String url = response.body().string(); + + // Return the result to JavaScript + JSObject ret = new JSObject(); + ret.put("url", url); + ret.put("fileName", fileName); + getActivity().runOnUiThread(() -> { + call.resolve(ret); + }); + + // Clean up temp file + tempFile.delete(); + } + } catch (Exception e) { + getActivity().runOnUiThread(() -> { + call.reject("Upload failed: " + e.getMessage(), e); + }); + } + }).start(); + } + + @PluginMethod + public void pickMedia(PluginCall call) { + Log.d(TAG, "pickMedia called"); + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + String[] mimeTypes = {"image/jpeg", "image/png", "video/mp4", "video/webm"}; + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + startActivityForResult(call, intent, "pickMediaResult"); + } + + @ActivityCallback + private void pickMediaResult(PluginCall call, ActivityResult result) { + Log.d(TAG, "pickMediaResult callback received"); + if (call == null) { + return; + } + + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null) { + Uri uri = data.getData(); + try { + // Get file name + String fileName = getFileNameFromUri(uri); + + // Get mime type + String mimeType = getContext().getContentResolver().getType(uri); + if (mimeType == null) { + mimeType = "application/octet-stream"; + } + + // Read file into a byte array + InputStream inputStream = getContext().getContentResolver().openInputStream(uri); + ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int len; + while ((len = inputStream.read(buffer)) != -1) { + byteBuffer.write(buffer, 0, len); + } + byte[] fileBytes = byteBuffer.toByteArray(); + inputStream.close(); + + // Convert to base64 + String base64Data = android.util.Base64.encodeToString(fileBytes, android.util.Base64.DEFAULT); + + // Return data to JavaScript + JSObject ret = new JSObject(); + ret.put("data", base64Data); + ret.put("fileName", fileName); + ret.put("mimeType", mimeType); + call.resolve(ret); + } catch (Exception e) { + call.reject("Failed to read file: " + e.getMessage(), e); + } + } else { + call.reject("No data received"); + } + } else { + call.reject("File selection cancelled"); + } + } + + // Helper method to get file name from URI + private String getFileNameFromUri(Uri uri) { + String result = null; + if (uri.getScheme().equals("content")) { + Cursor cursor = getContext().getContentResolver().query(uri, null, null, null, null); + try { + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (nameIndex >= 0) { + result = cursor.getString(nameIndex); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + if (result == null) { + result = uri.getPath(); + int cut = result.lastIndexOf('/'); + if (cut != -1) { + result = result.substring(cut + 1); + } + } + return result; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/seedit/android/FileUtils.java b/android/app/src/main/java/seedit/android/FileUtils.java new file mode 100644 index 00000000..f8e3b977 --- /dev/null +++ b/android/app/src/main/java/seedit/android/FileUtils.java @@ -0,0 +1,50 @@ +package seedit.android; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; + +public class FileUtils { + public static File getFileFromUri(Context context, Uri uri) throws Exception { + String fileName = getFileName(context, uri); + File file = new File(context.getCacheDir(), fileName); + + try (InputStream inputStream = context.getContentResolver().openInputStream(uri); + FileOutputStream outputStream = new FileOutputStream(file)) { + byte[] buffer = new byte[4096]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + outputStream.flush(); + return file; + } + } + + private static String getFileName(Context context, Uri uri) { + String result = null; + if (uri.getScheme().equals("content")) { + try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (columnIndex != -1) { + result = cursor.getString(columnIndex); + } + } + } + } + if (result == null) { + result = uri.getPath(); + int cut = result.lastIndexOf('/'); + if (cut != -1) { + result = result.substring(cut + 1); + } + } + return result; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/seedit/android/MainActivity.java b/android/app/src/main/java/seedit/android/MainActivity.java index 23bac535..540d6d58 100644 --- a/android/app/src/main/java/seedit/android/MainActivity.java +++ b/android/app/src/main/java/seedit/android/MainActivity.java @@ -1,5 +1,12 @@ package seedit.android; +import android.os.Bundle; import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(FileUploaderPlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/android/build.gradle b/android/build.gradle index 10c52591..30927b6a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,8 +7,8 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath 'com.google.gms:google-services:4.3.5' + classpath 'com.android.tools.build:gradle:8.7.0' + classpath 'com.google.gms:google-services:4.3.10' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/android/gradle.properties b/android/gradle.properties index 0566c221..7c3f8712 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -22,3 +22,6 @@ org.gradle.jvmargs=-Xmx1536m android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/capacitor.config.ts b/capacitor.config.ts index 50564cb6..91807e84 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -8,7 +8,10 @@ const config: CapacitorConfig = { plugins: { CapacitorHttp: { enabled: true, - } + }, + FileUploader: { + enabled: true, + }, }, server: { androidScheme: 'https' diff --git a/electron/main.js b/electron/main.js index 12d1230a..971c0e38 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,15 +1,21 @@ import './log.js'; -import { app, BrowserWindow, Menu, MenuItem, Tray, screen as electronScreen, shell, dialog, nativeTheme, ipcMain } from 'electron'; +import { app, BrowserWindow, Menu, MenuItem, Tray, shell, dialog, nativeTheme, ipcMain } from 'electron'; import isDev from 'electron-is-dev'; import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import EnvPaths from 'env-paths'; import startIpfs from './start-ipfs.js'; import './start-plebbit-rpc.js'; -import { URL, fileURLToPath } from 'node:url'; +import { URL } from 'node:url'; import contextMenu from 'electron-context-menu'; import packageJson from '../package.json' with { type: 'json' }; -const dirname = path.join(path.dirname(fileURLToPath(import.meta.url))); +import FormData from 'form-data'; +import fetch from 'node-fetch'; +import { createReadStream } from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(__filename); let startIpfsError; startIpfs.onError = (error) => { @@ -78,7 +84,8 @@ const createMainWindow = () => { nodeIntegration: false, contextIsolation: true, devTools: true, // TODO: change to isDev when no bugs left - preload: path.join(dirname, 'preload.js'), + preload: path.join(dirname, 'preload.mjs'), + sandbox: false, // Required for ESM preload scripts }, }); @@ -316,3 +323,117 @@ app.on('window-all-closed', () => { app.quit(); } }); + +// Setup FileUploader plugin +ipcMain.handle('plugin:file-uploader:pickAndUploadMedia', async (event) => { + try { + const mainWindow = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'Images & Videos', extensions: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'] }], + }); + + if (result.canceled || result.filePaths.length === 0) { + throw new Error('File selection cancelled'); + } + + const filePath = result.filePaths[0]; + const fileName = path.basename(filePath); + + // Create form data for upload + const formData = new FormData(); + formData.append('reqtype', 'fileupload'); + formData.append('fileToUpload', createReadStream(filePath)); + + // Upload to catbox.moe + const response = await fetch('https://catbox.moe/user/api.php', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + + const url = await response.text(); + return { url, fileName }; + } catch (error) { + console.error('FileUploader error:', error); + throw error; + } +}); + +ipcMain.handle('plugin:file-uploader:uploadMedia', async (event, fileData) => { + try { + console.log('uploadMedia handler called with data:', typeof fileData); + + // Create form data for upload + const formData = new FormData(); + formData.append('reqtype', 'fileupload'); + + // Handle different types of inputs + if (fileData.fileData && fileData.fileName) { + // Convert base64 to buffer + const buffer = Buffer.from(fileData.fileData, 'base64'); + formData.append('fileToUpload', buffer, fileData.fileName); + } else { + throw new Error('Invalid file data'); + } + + // Upload to catbox.moe + const response = await fetch('https://catbox.moe/user/api.php', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + + const url = await response.text(); + return { url, fileName: fileData.fileName || 'uploaded-file' }; + } catch (error) { + console.error('FileUploader uploadMedia error:', error); + throw error; + } +}); + +// Add the pickMedia handler +ipcMain.handle('plugin:file-uploader:pickMedia', async (event) => { + try { + const mainWindow = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'Images & Videos', extensions: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'] }], + }); + + if (result.canceled || result.filePaths.length === 0) { + throw new Error('File selection cancelled'); + } + + const filePath = result.filePaths[0]; + const fileName = path.basename(filePath); + + // Read the file as base64 + const fileBuffer = fs.readFileSync(filePath); + const base64Data = fileBuffer.toString('base64'); + + // Determine mime type from extension + const ext = path.extname(fileName).toLowerCase(); + let mimeType = 'application/octet-stream'; + if (ext === '.jpg' || ext === '.jpeg') mimeType = 'image/jpeg'; + else if (ext === '.png') mimeType = 'image/png'; + else if (ext === '.gif') mimeType = 'image/gif'; + else if (ext === '.mp4') mimeType = 'video/mp4'; + else if (ext === '.webm') mimeType = 'video/webm'; + + return { + data: base64Data, + fileName, + mimeType, + }; + } catch (error) { + console.error('FileUploader pickMedia error:', error); + throw error; + } +}); diff --git a/electron/preload.js b/electron/preload.mjs similarity index 50% rename from electron/preload.js rename to electron/preload.mjs index 6a751f92..e31a9907 100644 --- a/electron/preload.js +++ b/electron/preload.mjs @@ -1,4 +1,9 @@ -const { contextBridge, ipcRenderer } = require('electron'); +import { contextBridge, ipcRenderer } from 'electron'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(__filename); // dev uses http://localhost, prod uses file://...index.html const isDev = window.location.protocol === 'http:'; @@ -17,3 +22,19 @@ ipcRenderer.send('get-plebbit-rpc-auth-key'); // uncomment for logs // localStorage.debug = 'plebbit-js:*,plebbit-react-hooks:*,seedit:*' + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +contextBridge.exposeInMainWorld('electron', { + invoke: (channel, ...args) => { + const validChannels = [ + 'plugin:file-uploader:pickAndUploadMedia', + 'plugin:file-uploader:uploadMedia', + 'plugin:file-uploader:pickMedia' + ]; + if (validChannels.includes(channel)) { + return ipcRenderer.invoke(channel, ...args); + } + throw new Error(`Unauthorized IPC channel: ${channel}`); + } +}); diff --git a/electron/src/main.ts b/electron/src/main.ts new file mode 100644 index 00000000..dedeee0d --- /dev/null +++ b/electron/src/main.ts @@ -0,0 +1,60 @@ +import { app, BrowserWindow } from 'electron'; +import * as path from 'path'; +import { setupFileUploaderPlugin } from './plugins/file-uploader'; + +let mainWindow: BrowserWindow | null = null; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js'), + }, + }); + + // Prevent file drops on the window + mainWindow.webContents.on('will-navigate', (e, url) => { + if (url !== mainWindow.webContents.getURL()) { + e.preventDefault(); + } + }); + + // Prevent default file drop behavior + mainWindow.webContents.on('drop', (e) => { + e.preventDefault(); + }); + + mainWindow.webContents.on('dragover', (e) => { + e.preventDefault(); + }); + + // Setup the FileUploader plugin + setupFileUploaderPlugin(mainWindow); + + // Load your app + if (process.env.NODE_ENV === 'development') { + mainWindow.loadURL('http://localhost:3000'); + mainWindow.webContents.openDevTools(); + } else { + mainWindow.loadFile(path.join(__dirname, '../build/index.html')); + } +} + +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); diff --git a/electron/src/plugins/file-uploader.ts b/electron/src/plugins/file-uploader.ts new file mode 100644 index 00000000..581c6a6e --- /dev/null +++ b/electron/src/plugins/file-uploader.ts @@ -0,0 +1,128 @@ +import { dialog, ipcMain } from 'electron'; +import { createReadStream } from 'fs'; +import fetch from 'node-fetch'; +import FormData from 'form-data'; +import path from 'path'; +import fs from 'fs'; +import { app } from 'electron'; + +export function setupFileUploaderPlugin(mainWindow: Electron.BrowserWindow) { + // Handle the file upload request from the renderer + ipcMain.handle('plugin:file-uploader:pickAndUploadMedia', async () => { + try { + // Open file dialog + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'Images & Videos', extensions: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'] }], + }); + + if (result.canceled || result.filePaths.length === 0) { + throw new Error('File selection cancelled'); + } + + const filePath = result.filePaths[0]; + const fileName = path.basename(filePath); + + // Create form data for upload + const formData = new FormData(); + formData.append('reqtype', 'fileupload'); + formData.append('fileToUpload', createReadStream(filePath)); + + // Upload to catbox.moe + const response = await fetch('https://catbox.moe/user/api.php', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + + const url = await response.text(); + return { url, fileName }; + } catch (error) { + console.error('FileUploader error:', error); + throw error; + } + }); + + // Add the uploadMedia handler + ipcMain.handle('plugin:file-uploader:uploadMedia', async (_, fileData) => { + try { + const formData = new FormData(); + formData.append('reqtype', 'fileupload'); + + if (fileData.fileData && fileData.fileName) { + // If we have base64 data from pickMedia + const tempFilePath = path.join(app.getPath('temp'), fileData.fileName); + + // Write the base64 data to a temp file + fs.writeFileSync(tempFilePath, Buffer.from(fileData.fileData, 'base64')); + + // Append the file to the form + formData.append('fileToUpload', createReadStream(tempFilePath)); + + // Upload to catbox.moe + const response = await fetch('https://catbox.moe/user/api.php', { + method: 'POST', + body: formData, + }); + + // Clean up temp file + fs.unlinkSync(tempFilePath); + + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + + const url = await response.text(); + return { url, fileName: fileData.fileName }; + } else { + throw new Error('Invalid file data'); + } + } catch (error) { + console.error('FileUploader uploadMedia error:', error); + throw error; + } + }); + + // Add the pickMedia handler to Electron + ipcMain.handle('plugin:file-uploader:pickMedia', async () => { + try { + // Open file dialog + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'Images & Videos', extensions: ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm'] }], + }); + + if (result.canceled || result.filePaths.length === 0) { + throw new Error('File selection cancelled'); + } + + const filePath = result.filePaths[0]; + const fileName = path.basename(filePath); + + // Read the file as base64 + const fileBuffer = fs.readFileSync(filePath); + const base64Data = fileBuffer.toString('base64'); + + // Determine mime type from extension + const ext = path.extname(fileName).toLowerCase(); + let mimeType = 'application/octet-stream'; + if (ext === '.jpg' || ext === '.jpeg') mimeType = 'image/jpeg'; + else if (ext === '.png') mimeType = 'image/png'; + else if (ext === '.gif') mimeType = 'image/gif'; + else if (ext === '.mp4') mimeType = 'video/mp4'; + else if (ext === '.webm') mimeType = 'video/webm'; + + return { + data: base64Data, + fileName, + mimeType, + }; + } catch (error) { + console.error('FileUploader pickMedia error:', error); + throw error; + } + }); +} diff --git a/electron/src/preload.ts b/electron/src/preload.ts new file mode 100644 index 00000000..1c023799 --- /dev/null +++ b/electron/src/preload.ts @@ -0,0 +1,14 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +contextBridge.exposeInMainWorld('electron', { + invoke: (channel: string, ...args: any[]) => { + // whitelist channels + const validChannels = ['plugin:file-uploader:pickAndUploadMedia', 'plugin:file-uploader:uploadMedia', 'plugin:file-uploader:pickMedia']; + if (validChannels.includes(channel)) { + return ipcRenderer.invoke(channel, ...args); + } + throw new Error(`Unauthorized IPC channel: ${channel}`); + }, +}); diff --git a/package.json b/package.json index f35b2ecf..f51c3af4 100644 --- a/package.json +++ b/package.json @@ -21,15 +21,18 @@ "electron-context-menu": "3.3.0", "electron-is-dev": "2.0.0", "ext-name": "5.0.0", + "form-data": "4.0.2", "i18next": "23.5.1", "i18next-browser-languagedetector": "7.1.0", "i18next-http-backend": "2.2.2", "json-stringify-pretty-compact": "4.0.0", "lodash": "4.17.21", "memoizee": "0.4.15", + "node-fetch": "2", "prettier": "3.0.3", "react": "19.0.0", "react-dom": "19.0.0", + "react-dropzone": "14.3.8", "react-i18next": "13.2.2", "react-markdown": "8.0.6", "react-router-dom": "6.16.0", @@ -84,6 +87,7 @@ "@capacitor/core": "5.0.0", "@electron/rebuild": "3.6.0", "@types/memoizee": "0.4.9", + "@types/node-fetch": "2", "@typescript-eslint/eslint-plugin": "8.26.0", "@typescript-eslint/parser": "8.26.0", "@vitejs/plugin-react": "4.3.4", diff --git a/public/assets/camera.png b/public/assets/camera.png new file mode 100644 index 00000000..f1e3d9a6 Binary files /dev/null and b/public/assets/camera.png differ diff --git a/public/assets/upload.png b/public/assets/upload.png new file mode 100644 index 00000000..1ff6607f Binary files /dev/null and b/public/assets/upload.png differ diff --git a/src/plugins/file-uploader.ts b/src/plugins/file-uploader.ts new file mode 100644 index 00000000..18ce4f6d --- /dev/null +++ b/src/plugins/file-uploader.ts @@ -0,0 +1,52 @@ +import { registerPlugin } from '@capacitor/core'; +import { Capacitor } from '@capacitor/core'; + +export interface FileUploaderPlugin { + pickAndUploadMedia(): Promise<{ url: string; fileName: string }>; + uploadMedia(fileData: { fileData?: string; fileName: string } | File): Promise<{ url: string; fileName: string }>; + pickMedia(): Promise<{ data: string; fileName: string; mimeType: string }>; +} + +const FileUploader = Capacitor.isNativePlatform() + ? registerPlugin('FileUploader') + : { + pickAndUploadMedia: async () => { + // For Electron, we'll use IPC + if (window.electron) { + return window.electron.invoke('plugin:file-uploader:pickAndUploadMedia'); + } + throw new Error('FileUploader is not supported on this platform'); + }, + uploadMedia: async (fileData: { fileData?: string; fileName: string } | File) => { + if (window.electron) { + return window.electron.invoke('plugin:file-uploader:uploadMedia', fileData); + } + throw new Error('FileUploader is not supported on this platform'); + }, + pickMedia: async () => { + console.log('Calling pickMedia'); + if (window.electron) { + console.log('window.electron exists, invoking IPC'); + try { + const result = await window.electron.invoke('plugin:file-uploader:pickMedia'); + console.log('IPC result:', result); + return result; + } catch (error) { + console.error('IPC error:', error); + throw error; + } + } + throw new Error('FileUploader is not supported on this platform'); + }, + }; + +export default FileUploader; + +// Add TypeScript type declaration for Electron +declare global { + interface Window { + electron?: { + invoke(channel: string, ...args: any[]): Promise; + }; + } +} diff --git a/src/themes.css b/src/themes.css index 94944dde..b76a38d3 100644 --- a/src/themes.css +++ b/src/themes.css @@ -75,6 +75,8 @@ --thumbnail-icon-nsfw: url('/assets/thumbnail-icon-nsfw-dark.png'); --thumbnail-icon-spoiler: url('/assets/thumbnail-icon-spoiler-dark.png'); --thumbnail-icon-text: url('/assets/thumbnail-icon-text-dark.png'); + --upload-box-background: rgb(15, 15, 15); + --upload-box-border: 2px dashed #404649; --yellow: rgb(200, 171, 0); --yellow-box-background: rgb(56, 45, 0); --yellow-box-contrast: rgb(163, 130, 0); @@ -162,6 +164,8 @@ --thumbnail-icon-nsfw: url('/assets/thumbnail-icon-nsfw.png'); --thumbnail-icon-spoiler: url('/assets/thumbnail-icon-spoiler.png'); --thumbnail-icon-text: url('/assets/thumbnail-icon-text.png'); + --upload-box-background: #fafafa; + --upload-box-border: 2px dashed #bbb; --yellow: goldenrod; --yellow-box-background: #fff7d7; --yellow-box-contrast: #ffd634; diff --git a/src/views/submit-page/submit-page.module.css b/src/views/submit-page/submit-page.module.css index d4bb5fac..4698a68d 100644 --- a/src/views/submit-page/submit-page.module.css +++ b/src/views/submit-page/submit-page.module.css @@ -1,6 +1,7 @@ .content { margin: 7px 5px 50px 5px; color: var(--text); + cursor: default; } .infobar { @@ -189,6 +190,22 @@ text-transform: capitalize; } +.urlCancelButton { + display: block; + position: absolute; + right: 6px; + top: 6px; + height: 18px; + width: 18px; + text-align: center; + background: var(--gray-light); + line-height: 18px; + font-size: 11px; + border-radius: 50%; + cursor: pointer; + z-index: 1; +} + .logoError { padding-left: 10px; } @@ -232,4 +249,99 @@ .input { min-width: 100%; } -} \ No newline at end of file +} + +.uploadBox { + position: relative; + content: ''; + width: 100%; + box-sizing: border-box; + padding: 20px 10px; + border: var(--upload-box-border); + background-color: var(--upload-box-background); + text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.uploadBox.dragging { + border-width: 5px; +} + +.uploading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + height: 100%; +} + +.cameraIcon { + background-image: url('/assets/camera.png'); + display: inline-block; + width: 54px; + height: 40px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +@media (max-width: 640px) { + .cameraIcon { + width: 40px; + height: 30px; + } +} + +.dropText { + display: inline; + padding: 0 5px; +} + +@media (max-width: 640px) { + .dropText { + font-size: 15px; + } +} + +.fileUploadIcon { + background-image: url('/assets/upload.png'); + display: inline-block; + width: 20px; + height: 16px; + margin-right: 5px; + background-size: contain; + pointer-events: none; + vertical-align: middle; + background-repeat: no-repeat; + transform: translateY(-1px); +} + +.uploadBox label { + color: var(--over18-alert-color); + display: inline-block; + text-align: center; + text-transform: uppercase; + font-weight: 500; + cursor: pointer; + background-image: none; + border: 1px solid transparent; + white-space: nowrap; + padding: 5px 12px 3px 12px; + font-size: 12px; + border-radius: 3px; + margin: 3px 5px; + background-color: var(--button-background-color); + border-bottom: 2px solid var(--button-border-bottom); + user-select: none; + text-transform: uppercase; + font-weight: 700; + font-size: 13px; +} + +.uploadBox label:hover { + background-color: var(--button-background-color-hover); +} diff --git a/src/views/submit-page/submit-page.tsx b/src/views/submit-page/submit-page.tsx index 9dc4d836..8b60ba8d 100644 --- a/src/views/submit-page/submit-page.tsx +++ b/src/views/submit-page/submit-page.tsx @@ -1,22 +1,25 @@ -import { ChangeEvent, useCallback, useEffect, useState } from 'react'; +import { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; +import { Capacitor } from '@capacitor/core'; +import { useDropzone } from 'react-dropzone'; import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js'; import { useAccount, usePublishComment, useSubplebbit } from '@plebbit/plebbit-react-hooks'; import usePublishPostStore from '../../stores/use-publish-post-store'; import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits'; +import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; import { getLinkMediaInfo } from '../../lib/utils/media-utils'; import { isValidURL } from '../../lib/utils/url-utils'; import Embed from '../../components/post/embed'; +import LoadingEllipsis from '../../components/loading-ellipsis'; import Markdown from '../../components/markdown'; +import FileUploader from '../../plugins/file-uploader'; import styles from './submit-page.module.css'; -import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; -const UrlField = () => { +const UrlField = ({ url, setUrl, urlRef }: { url: string; setUrl: (url: string) => void; urlRef: React.RefObject }) => { const { t } = useTranslation(); const { setPublishPostStore } = usePublishPostStore(); const [mediaError, setMediaError] = useState(false); - const [url, setUrl] = useState(''); const mediaInfo = getLinkMediaInfo(url); const mediaType = mediaInfo?.type; @@ -46,7 +49,13 @@ const UrlField = () => { )}
+ {url && ( + setUrl('')}> + x + + )} { ); }; +const isAndroid = Capacitor.getPlatform() === 'android'; +const isElectron = window.isElectron === true; + const Submit = () => { const { t } = useTranslation(); const params = useParams(); @@ -77,6 +89,8 @@ const Submit = () => { const [inputAddress, setInputAddress] = useState(params.subplebbitAddress || ''); const [selectedSubplebbit, setSelectedSubplebbit] = useState(params.subplebbitAddress || ''); + const [url, setUrl] = useState(''); + const urlRef = useRef(null); useEffect(() => { return () => { @@ -162,14 +176,14 @@ const Submit = () => { if (activeDropdownIndex !== -1) { const selectedAddress = filteredSubplebbitAddresses[activeDropdownIndex]; setSelectedSubplebbit(selectedAddress); - setPublishPostStore({ subplebbitAddress: selectedAddress }); + setSubmitStoreHook({ subplebbitAddress: selectedAddress }); setInputAddress(selectedAddress); } setActiveDropdownIndex(-1); setIsInputAddressFocused(false); } }, - [filteredSubplebbitAddresses, activeDropdownIndex, setPublishPostStore], + [filteredSubplebbitAddresses, activeDropdownIndex, setSubmitStoreHook], ); useEffect(() => { @@ -217,11 +231,130 @@ const Submit = () => { const handleSubplebbitSelect = (subplebbitAddress: string) => { setSelectedSubplebbit(subplebbitAddress); setInputAddress(subplebbitAddress); - setPublishPostStore({ subplebbitAddress: subplebbitAddress }); + setSubmitStoreHook({ subplebbitAddress: subplebbitAddress }); setIsInputAddressFocused(false); setActiveDropdownIndex(-1); }; + // on android or electron, auto upload file to image hosting sites with open api + const [isUploading, setIsUploading] = useState(false); + const [isChoosingFile, setIsChoosingFile] = useState(false); + + const onDrop = useCallback( + async (acceptedFiles: File[]) => { + if (!(isAndroid || isElectron)) { + if (window.confirm('This feature is only available on Seedit Android app, or desktop app (win/mac/linux) versions.\n\nGo to download links page on GitHub?')) { + const link = document.createElement('a'); + link.href = 'https://github.com/plebbit/seedit/releases/latest'; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.click(); + } + return; + } + + if (acceptedFiles.length > 0) { + try { + setIsChoosingFile(false); + setIsUploading(true); + + // for Electron, we need to convert the File to a format that can be sent via IPC + if (isElectron) { + const file = acceptedFiles[0]; + const reader = new FileReader(); + + const fileData = await new Promise((resolve, reject) => { + reader.onload = () => { + const base64data = reader.result?.toString().split(',')[1]; + resolve({ + fileData: base64data, + fileName: file.name, + }); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const result = await FileUploader.uploadMedia(fileData as { fileData?: string; fileName: string }); + if (result.url) { + setUrl(result.url); + setPublishPostStore({ link: result.url || undefined }); + } + } else if (isAndroid) { + // android can handle File objects directly + const result = await FileUploader.uploadMedia(acceptedFiles[0]); + if (result.url) { + setUrl(result.url); + setPublishPostStore({ link: result.url || undefined }); + } + } + } catch (error) { + console.error('Upload failed:', error); + if (error instanceof Error && !error.message.includes('File selection cancelled')) { + alert(`${t('upload_failed')}: ${error.message}`); + } + } finally { + setIsUploading(false); + } + } + }, + [setUrl, setPublishPostStore, t], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + noClick: true, + accept: { + 'image/*': [], + 'video/*': [], + 'audio/*': [], + }, + }); + + const handleUpload = async () => { + if (!(isAndroid || isElectron)) { + if (window.confirm('This feature is only available on Seedit Android app, or desktop app (win/mac/linux) versions.\n\nGo to download links page on GitHub?')) { + const link = document.createElement('a'); + link.href = 'https://github.com/plebbit/seedit/releases/latest'; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.click(); + } + return; + } + + try { + setIsChoosingFile(true); + + const pickedFile = await FileUploader.pickMedia(); // base64 data + setIsChoosingFile(false); + + setIsUploading(true); + + const uploadResult = await FileUploader.uploadMedia({ + fileData: pickedFile.data, + fileName: pickedFile.fileName, + }); + + if (uploadResult?.url) { + setUrl(uploadResult.url); + setPublishPostStore({ link: uploadResult.url }); + } else { + throw new Error('No URL returned from upload'); + } + } catch (error) { + console.error('Process failed:', error); + if (error instanceof Error && !error.message.includes('File selection cancelled')) { + alert(`${t('upload_failed')}: ${error.message}`); + } else if (typeof error === 'string' && !error.includes('File selection cancelled')) { + alert(`${t('upload_failed')}: ${error}`); + } + } finally { + setIsChoosingFile(false); + setIsUploading(false); + } + }; + return (

@@ -231,17 +364,37 @@ const Submit = () => { values={{ link: selectedSubplebbitData?.title || selectedSubplebbitData?.shortAddress || 'seedit', }} - components={{ - 1: selectedSubplebbitData?.shortAddress ? : <>, - }} + components={[selectedSubplebbitData?.shortAddress ? : ]} />

{isOffline && selectedSubplebbit &&
{offlineTitle}
}
- +
+ {url.length === 0 && ( +
+ image/video/audio +
+ {isUploading ? ( +
+ +
+ ) : ( +
+ +
+
Drop here or
+
+
+ )}
{t('title')}
diff --git a/yarn.lock b/yarn.lock index 5d2bf5b5..f325cdd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3753,6 +3753,14 @@ resolved "https://registry.yarnpkg.com/@types/murmurhash3js-revisited/-/murmurhash3js-revisited-3.0.3.tgz#94e247168a18342477639126da8f01353437e8d0" integrity sha512-QvlqvYtGBYIDeO8dFdY4djkRubcrc+yTJtBc7n8VZPlJDUS/00A+PssbvERM8f9bYRmcaSEHPZgZojeQj7kzAA== +"@types/node-fetch@2": + version "2.6.12" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03" + integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node@*", "@types/node@>=13.7.0", "@types/node@^22.7.7": version "22.13.10" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.10.tgz#df9ea358c5ed991266becc3109dc2dc9125d77e4" @@ -4557,6 +4565,11 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +attr-accept@^2.2.4: + version "2.2.5" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" + integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== + available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -7388,6 +7401,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-selector@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4" + integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig== + dependencies: + tslib "^2.7.0" + file-type@16.5.4: version "16.5.4" resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd" @@ -7519,7 +7539,7 @@ foreground-child@^3.1.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" -form-data@^4.0.0: +form-data@^4.0.0, form-data@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== @@ -10714,7 +10734,7 @@ node-domexception@^2.0.1: resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-2.0.1.tgz#83b0d101123b5bbf91018fd569a58b88ae985e5b" integrity sha512-M85rnSC7WQ7wnfQTARPT4LrK7nwCHLdDFOCcItZMhTQjyCebJH8GciKqYJNgaOFZs9nFmTmd/VMyi3OW5jA47w== -node-fetch@^2.6.1, node-fetch@^2.6.11, node-fetch@^2.6.7, node-fetch@^2.7.0: +node-fetch@2, node-fetch@^2.6.1, node-fetch@^2.6.11, node-fetch@^2.6.7, node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -11710,6 +11730,15 @@ react-dom@19.0.0: dependencies: scheduler "^0.25.0" +react-dropzone@^14.3.8: + version "14.3.8" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.8.tgz#a7eab118f8a452fe3f8b162d64454e81ba830582" + integrity sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug== + dependencies: + attr-accept "^2.2.4" + file-selector "^2.1.0" + prop-types "^15.8.1" + react-i18next@13.2.2: version "13.2.2" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-13.2.2.tgz#b1e78ed66a54f4bc819616f68b98221e1b1a1936" @@ -13354,7 +13383,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: +tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==