From 96a4aaec0ed5e49c3f39fdc37499e30110d74321 Mon Sep 17 00:00:00 2001 From: plebeius Date: Wed, 18 Feb 2026 18:51:02 +0800 Subject: [PATCH] feat(media-hosting): add multi-provider configurable upload with fallback Add Random/Preferred/None modes, preferred provider selection, and provider-order fallback. Catbox/Imgur/PostImages on Android via WebView; Electron CDP automation for Imgur/PostImages. Hide upload controls on web runtime. --- .../plebchan/android/FileUploaderPlugin.java | 280 ++++++++++++++---- .../android/MediaUploadAutomationRunner.java | 200 +++++++++++++ .../plebchan/android/MediaUploadRecipes.java | 143 +++++++++ electron/main.js | 10 + electron/media-upload-automation.js | 216 ++++++++++++++ electron/media-upload-recipes.js | 52 ++++ electron/preload.mjs | 5 +- public/translations/ar/default.json | 11 +- public/translations/bn/default.json | 11 +- public/translations/cs/default.json | 11 +- public/translations/da/default.json | 11 +- public/translations/de/default.json | 11 +- public/translations/el/default.json | 11 +- public/translations/en/default.json | 11 +- public/translations/es/default.json | 11 +- public/translations/fa/default.json | 11 +- public/translations/fi/default.json | 11 +- public/translations/fil/default.json | 11 +- public/translations/fr/default.json | 11 +- public/translations/he/default.json | 11 +- public/translations/hi/default.json | 11 +- public/translations/hu/default.json | 11 +- public/translations/id/default.json | 11 +- public/translations/it/default.json | 11 +- public/translations/ja/default.json | 11 +- public/translations/ko/default.json | 11 +- public/translations/mr/default.json | 11 +- public/translations/nl/default.json | 11 +- public/translations/no/default.json | 11 +- public/translations/pl/default.json | 11 +- public/translations/pt/default.json | 11 +- public/translations/ro/default.json | 11 +- public/translations/ru/default.json | 11 +- public/translations/sq/default.json | 11 +- public/translations/sv/default.json | 11 +- public/translations/te/default.json | 11 +- public/translations/th/default.json | 11 +- public/translations/tr/default.json | 11 +- public/translations/uk/default.json | 11 +- public/translations/ur/default.json | 11 +- public/translations/vi/default.json | 11 +- public/translations/zh/default.json | 11 +- src/components/post-form/post-form.tsx | 5 +- src/components/reply-modal/reply-modal.tsx | 5 +- .../interface-settings.module.css | 17 ++ .../__tests__/media-hosting-settings.test.tsx | 156 ++++++++-- .../media-hosting-settings.tsx | 69 ++++- src/globals.d.ts | 9 +- src/hooks/__tests__/use-file-upload.test.ts | 142 +++++++-- src/hooks/use-file-upload.ts | 39 ++- .../__tests__/direct-url.test.ts | 39 +++ .../__tests__/provider-order.test.ts | 97 ++++++ .../__tests__/show-upload-controls.test.ts | 19 ++ src/lib/media-hosting/direct-url.ts | 12 + src/lib/media-hosting/error-format.ts | 24 ++ src/lib/media-hosting/provider-order.ts | 37 +++ src/lib/media-hosting/providers.ts | 34 +++ src/lib/media-hosting/show-upload-controls.ts | 16 + src/lib/media-hosting/types.ts | 30 ++ src/lib/media-hosting/upload-orchestrator.ts | 50 ++++ src/plugins/file-uploader.ts | 14 +- .../__tests__/use-media-hosting-store.test.ts | 52 +++- src/stores/use-media-hosting-store.ts | 35 ++- 63 files changed, 1991 insertions(+), 201 deletions(-) create mode 100644 android/app/src/main/java/plebchan/android/MediaUploadAutomationRunner.java create mode 100644 android/app/src/main/java/plebchan/android/MediaUploadRecipes.java create mode 100644 electron/media-upload-automation.js create mode 100644 electron/media-upload-recipes.js create mode 100644 src/lib/media-hosting/__tests__/direct-url.test.ts create mode 100644 src/lib/media-hosting/__tests__/provider-order.test.ts create mode 100644 src/lib/media-hosting/__tests__/show-upload-controls.test.ts create mode 100644 src/lib/media-hosting/direct-url.ts create mode 100644 src/lib/media-hosting/error-format.ts create mode 100644 src/lib/media-hosting/provider-order.ts create mode 100644 src/lib/media-hosting/providers.ts create mode 100644 src/lib/media-hosting/show-upload-controls.ts create mode 100644 src/lib/media-hosting/types.ts create mode 100644 src/lib/media-hosting/upload-orchestrator.ts diff --git a/android/app/src/main/java/plebchan/android/FileUploaderPlugin.java b/android/app/src/main/java/plebchan/android/FileUploaderPlugin.java index 37f52f30..34d0089a 100644 --- a/android/app/src/main/java/plebchan/android/FileUploaderPlugin.java +++ b/android/app/src/main/java/plebchan/android/FileUploaderPlugin.java @@ -3,21 +3,27 @@ package fivechan.android; import android.app.Activity; import android.content.Intent; import android.net.Uri; -import android.provider.MediaStore; import android.util.Log; +import androidx.activity.result.ActivityResult; +import androidx.appcompat.app.AppCompatActivity; + +import com.getcapacitor.JSArray; 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 com.getcapacitor.annotation.CapacitorPlugin; import java.io.File; -import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.json.JSONArray; import okhttp3.MediaType; import okhttp3.MultipartBody; @@ -30,9 +36,16 @@ import okhttp3.Response; public class FileUploaderPlugin extends Plugin { private static final String TAG = "FileUploaderPlugin"; + private static final String PROVIDER_CATBOX = "catbox"; + private static final long CATBOX_TIMEOUT_SEC = 30; + @PluginMethod public void pickAndUploadMedia(PluginCall call) { Log.d(TAG, "pickAndUploadMedia called"); + List providerOrder = parseProviderOrder(call); + if (providerOrder.isEmpty()) { + providerOrder.add(PROVIDER_CATBOX); + } Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("*/*"); String[] mimeTypes = {"image/jpeg", "image/png", "video/mp4", "video/webm"}; @@ -40,6 +53,29 @@ public class FileUploaderPlugin extends Plugin { startActivityForResult(call, intent, "pickFileResult"); } + private List parseProviderOrder(PluginCall call) { + List order = new ArrayList<>(); + JSArray arr = call.getArray("providerOrder"); + if (arr != null) { + for (int i = 0; i < arr.length(); i++) { + try { + Object o = arr.get(i); + if (o instanceof String) { + String p = (String) o; + if (PROVIDER_CATBOX.equals(p) + || MediaUploadRecipes.PROVIDER_IMGUR.equals(p) + || MediaUploadRecipes.PROVIDER_POSTIMAGES.equals(p)) { + order.add(p); + } + } + } catch (Exception e) { + Log.w(TAG, "Skip invalid provider at " + i, e); + } + } + } + return order; + } + @ActivityCallback private void pickFileResult(PluginCall call, ActivityResult result) { Log.d(TAG, "pickFileResult callback received"); @@ -47,64 +83,188 @@ public class FileUploaderPlugin extends Plugin { 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 { + if (result.getResultCode() != Activity.RESULT_OK) { call.reject("File selection cancelled"); + return; + } + + Intent data = result.getData(); + if (data == null) { + call.reject("No data received"); + return; + } + + Uri uri = data.getData(); + if (uri == null) { + call.reject("No URI received"); + return; + } + + List providerOrder = parseProviderOrder(call); + if (providerOrder.isEmpty()) { + providerOrder.add(PROVIDER_CATBOX); + } + + new Thread( + () -> { + try { + tryProvidersSequentially(uri, providerOrder, call); + } catch (Exception e) { + Log.e(TAG, "Upload failed", e); + if (!call.getData().has("_resolved")) { + call.reject("Upload failed: " + e.getMessage()); + } + } + }) + .start(); + } + + private void tryProvidersSequentially(Uri fileUri, List providerOrder, PluginCall call) { + List attempts = new ArrayList<>(); + StringBuilder errorSummary = new StringBuilder(); + + for (String provider : providerOrder) { + JSObject attempt = new JSObject(); + attempt.put("provider", provider); + + if (PROVIDER_CATBOX.equals(provider)) { + MediaUploadResult res = uploadToCatboxSync(fileUri); + attempt.put("success", res.success); + if (res.success) { + attempt.put("url", res.url); + resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts); + return; + } + attempt.put("error", res.error); + errorSummary.append(provider).append(": ").append(res.error).append("; "); + } else if (MediaUploadRecipes.PROVIDER_IMGUR.equals(provider) + || MediaUploadRecipes.PROVIDER_POSTIMAGES.equals(provider)) { + MediaUploadResult res = uploadViaWebViewSync(fileUri, provider); + attempt.put("success", res.success); + if (res.success) { + attempt.put("url", res.url); + resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts); + return; + } + attempt.put("error", res.error); + errorSummary.append(provider).append(": ").append(res.error).append("; "); + } + attempts.add(attempt); + } + + JSObject data = new JSObject(); + JSONArray arr = new JSONArray(); + for (JSObject a : attempts) { + arr.put(a); + } + data.put("attempts", arr); + call.reject("All providers failed: " + errorSummary.toString(), null, null, data); + } + + private void resolveWithSuccess( + PluginCall call, String url, String fileName, String provider, List attempts) { + JSObject ret = new JSObject(); + ret.put("url", url); + ret.put("fileName", fileName); + ret.put("provider", provider); + JSONArray arr = new JSONArray(); + for (JSObject a : attempts) { + arr.put(a); + } + ret.put("attempts", arr); + call.resolve(ret); + } + + private String getFileName(Uri uri) { + try { + File f = FileUtils.getFileFromUri(getContext(), uri); + return f != null ? f.getName() : "unknown"; + } catch (Exception e) { + return "unknown"; } } - 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()); + private MediaUploadResult uploadToCatboxSync(Uri fileUri) { + try { + File file = FileUtils.getFileFromUri(getContext(), fileUri); + if (file == null) { + return new MediaUploadResult(false, null, "Could not resolve file"); } - }).start(); + + JSObject statusUpdate = new JSObject(); + statusUpdate.put("status", "Uploading to catbox.moe..."); + notifyListeners("uploadStatus", statusUpdate); + + OkHttpClient client = + new OkHttpClient.Builder() + .connectTimeout(CATBOX_TIMEOUT_SEC, TimeUnit.SECONDS) + .writeTimeout(CATBOX_TIMEOUT_SEC, TimeUnit.SECONDS) + .readTimeout(CATBOX_TIMEOUT_SEC, 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()) { + return new MediaUploadResult( + false, null, "Unexpected response " + response.code()); + } + String url = response.body().string(); + Log.d(TAG, "Catbox upload successful. URL: " + url); + return new MediaUploadResult(true, url.trim(), null); + } + } catch (Exception e) { + Log.e(TAG, "Catbox upload failed", e); + return new MediaUploadResult(false, null, e.getMessage()); + } } -} \ No newline at end of file + + private MediaUploadResult uploadViaWebViewSync(Uri fileUri, String provider) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference resultRef = new AtomicReference<>(); + + AppCompatActivity activity = getActivity(); + if (activity == null) { + return new MediaUploadResult(false, null, "Activity unavailable"); + } + + String fileName = getFileName(fileUri); + MediaUploadCallback callback = + res -> { + resultRef.set(res); + latch.countDown(); + }; + + activity.runOnUiThread( + () -> { + MediaUploadAutomationRunner runner = + new MediaUploadAutomationRunner( + getContext(), fileUri, fileName, provider, callback); + runner.run(); + }); + + try { + boolean ok = latch.await(MediaUploadRecipes.UPLOAD_TIMEOUT_MS + 5000, TimeUnit.MILLISECONDS); + if (!ok) { + return new MediaUploadResult(false, null, "WebView upload timeout"); + } + MediaUploadResult r = resultRef.get(); + return r != null ? r : new MediaUploadResult(false, null, "No result"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new MediaUploadResult(false, null, "Interrupted"); + } + } +} diff --git a/android/app/src/main/java/plebchan/android/MediaUploadAutomationRunner.java b/android/app/src/main/java/plebchan/android/MediaUploadAutomationRunner.java new file mode 100644 index 00000000..efbdaafa --- /dev/null +++ b/android/app/src/main/java/plebchan/android/MediaUploadAutomationRunner.java @@ -0,0 +1,200 @@ +package fivechan.android; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +/** Result of a WebView-based upload attempt. */ +final class MediaUploadResult { + public final boolean success; + public final String url; + public final String error; + + public MediaUploadResult(boolean success, String url, String error) { + this.success = success; + this.url = url; + this.error = error; + } +} + +/** Callback for MediaUploadAutomationRunner. */ +interface MediaUploadCallback { + void onComplete(MediaUploadResult result); +} + +/** + * Non-interactive WebView automation for imgur/postimages uploads. + * Intercepts file chooser, feeds selected URI, polls for success/blocked, then tears down. + */ +@SuppressLint("SetJavaScriptEnabled") +public class MediaUploadAutomationRunner { + private static final String TAG = "MediaUploadAutomation"; + + private final Context context; + private final Uri fileUri; + private final String fileName; + private final String provider; + private final MediaUploadCallback callback; + + private WebView webView; + private ValueCallback filePathCallback; + private final Handler mainHandler; + private final Runnable pollRunnable; + private boolean finished; + private long startTime; + + public MediaUploadAutomationRunner( + Context context, Uri fileUri, String fileName, String provider, MediaUploadCallback callback) { + this.context = context.getApplicationContext(); + this.fileUri = fileUri; + this.fileName = fileName; + this.provider = provider; + this.callback = callback; + this.mainHandler = new Handler(Looper.getMainLooper()); + this.pollRunnable = this::pollForResult; + } + + public void run() { + mainHandler.post(this::startWebView); + } + + private void startWebView() { + webView = new WebView(context); + webView.setVisibility(android.view.View.GONE); + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowFileAccess(true); + settings.setAllowContentAccess(true); + settings.setLoadWithOverviewMode(true); + settings.setUseWideViewPort(true); + settings.setUserAgentString( + "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36"); + + webView.setWebChromeClient( + new WebChromeClient() { + @Override + public boolean onShowFileChooser( + WebView webView, + ValueCallback filePathCallback, + FileChooserParams fileChooserParams) { + MediaUploadAutomationRunner.this.filePathCallback = filePathCallback; + if (fileUri != null) { + filePathCallback.onReceiveValue(new Uri[] {fileUri}); + MediaUploadAutomationRunner.this.filePathCallback = null; + } + return true; + } + }); + + webView.setWebViewClient( + new WebViewClient() { + @Override + public void onPageFinished(WebView view, String url) { + MediaUploadAutomationRunner.this.mainHandler.postDelayed( + MediaUploadAutomationRunner.this::triggerFileInput, 1500); + } + }); + + String uploadUrl = MediaUploadRecipes.getUploadUrl(provider); + if (uploadUrl == null) { + finish(new MediaUploadResult(false, null, "Unknown provider: " + provider)); + return; + } + startTime = System.currentTimeMillis(); + webView.loadUrl(uploadUrl); + Log.d(TAG, "Loaded " + uploadUrl + " for provider " + provider); + } + + private void triggerFileInput() { + String js = MediaUploadRecipes.getTriggerFileInputJs(provider); + if (js == null) { + finish(new MediaUploadResult(false, null, "No trigger JS for " + provider)); + return; + } + webView.evaluateJavascript(js, value -> schedulePoll()); + } + + private void schedulePoll() { + mainHandler.removeCallbacks(pollRunnable); + mainHandler.postDelayed(pollRunnable, MediaUploadRecipes.POLL_INTERVAL_MS); + } + + private void pollForResult() { + if (finished) return; + + long elapsed = System.currentTimeMillis() - startTime; + if (elapsed >= MediaUploadRecipes.UPLOAD_TIMEOUT_MS) { + finish(new MediaUploadResult(false, null, "Upload timeout")); + return; + } + + String successJs = MediaUploadRecipes.getSuccessJs(provider); + String blockedJs = MediaUploadRecipes.getBlockedJs(provider); + if (successJs == null || blockedJs == null) { + finish(new MediaUploadResult(false, null, "Missing recipe for " + provider)); + return; + } + + webView.evaluateJavascript( + blockedJs, + blocked -> { + if ("true".equals(blocked)) { + finish(new MediaUploadResult(false, null, "Provider blocked (CAPTCHA/rate limit)")); + return; + } + webView.evaluateJavascript( + successJs, + url -> { + if (url != null && !"null".equals(url) && url.length() > 2) { + String cleaned = url.replaceAll("^\"|\"$", "").replace("\\u003d", "="); + if (cleaned.startsWith("http")) { + finish(new MediaUploadResult(true, cleaned, null)); + return; + } + } + schedulePoll(); + }); + }); + } + + private void finish(MediaUploadResult result) { + if (finished) return; + finished = true; + mainHandler.removeCallbacks(pollRunnable); + if (filePathCallback != null) { + try { + filePathCallback.onReceiveValue(null); + } catch (Exception ignored) {} + filePathCallback = null; + } + tearDown(); + callback.onComplete(result); + } + + private void tearDown() { + mainHandler.post( + () -> { + if (webView != null) { + try { + webView.stopLoading(); + webView.clearHistory(); + webView.clearCache(true); + webView.clearSslPreferences(); + webView.destroy(); + } catch (Exception e) { + Log.w(TAG, "Teardown warning", e); + } + webView = null; + } + }); + } +} diff --git a/android/app/src/main/java/plebchan/android/MediaUploadRecipes.java b/android/app/src/main/java/plebchan/android/MediaUploadRecipes.java new file mode 100644 index 00000000..01b56523 --- /dev/null +++ b/android/app/src/main/java/plebchan/android/MediaUploadRecipes.java @@ -0,0 +1,143 @@ +package fivechan.android; + +/** + * Provider-specific recipes for automated media upload via WebView. + * Mirrors electron/media-upload-recipes.js for imgur/postimages. + * Non-interactive automation; detects blocked states (captcha/login/challenge); + * extracts direct media URL candidates via selector candidate arrays. + */ +public final class MediaUploadRecipes { + + public static final String PROVIDER_IMGUR = "imgur"; + public static final String PROVIDER_POSTIMAGES = "postimages"; + + /** Max time to wait for upload completion (ms). */ + public static final long UPLOAD_TIMEOUT_MS = 45_000; + /** Poll interval for success/blocked checks (ms). */ + public static final long POLL_INTERVAL_MS = 500; + + private MediaUploadRecipes() {} + + public static String getUploadUrl(String provider) { + if (PROVIDER_IMGUR.equals(provider)) { + return "https://imgur.com/upload"; + } + if (PROVIDER_POSTIMAGES.equals(provider)) { + return "https://postimages.org"; + } + return null; + } + + /** + * JS to trigger file input click so WebChromeClient.onShowFileChooser fires. + * Uses candidate selectors; first match wins. + */ + public static String getTriggerFileInputJs(String provider) { + String[] candidates; + if (PROVIDER_IMGUR.equals(provider)) { + candidates = + new String[] { + "input[type=\"file\"]", + "input[type=file]", + "[data-file-input]", + }; + } else if (PROVIDER_POSTIMAGES.equals(provider)) { + candidates = + new String[] { + "input[type=\"file\"]", + "input[type=file]", + "#uploadFile", + ".fileinput", + }; + } else { + return null; + } + return buildTriggerFileInputJs(candidates); + } + + private static String buildTriggerFileInputJs(String[] selectors) { + StringBuilder sb = new StringBuilder("(function(){var s=["); + for (int i = 0; i < selectors.length; i++) { + if (i > 0) sb.append(","); + sb.append("\"").append(escapeJs(selectors[i])).append("\""); + } + sb.append("];for(var i=0;i 0) sb.append(","); + sb.append("\"").append(escapeJs(selectors[i])).append("\""); + } + sb.append("];for(var i=0;i 0) sb.append(","); + sb.append("\"").append(escapeJs(selectors[i])).append("\""); + } + sb.append("];for(var i=0;i { version: process.version, }; }); + +// Handle automated media upload (hidden BrowserWindow + CDP). Strict cleanup enforced in automation module. +ipcMain.handle('automate-upload-media', async (event, options) => { + const { provider, filePath } = options || {}; + if (!provider || typeof filePath !== 'string') { + throw new Error('automate-upload-media requires { provider, filePath }'); + } + return automateUploadMedia({ provider, filePath }); +}); diff --git a/electron/media-upload-automation.js b/electron/media-upload-automation.js new file mode 100644 index 00000000..1ee90c87 --- /dev/null +++ b/electron/media-upload-automation.js @@ -0,0 +1,216 @@ +/** + * Main-process automation for media upload via provider web UIs. + * Uses a hidden BrowserWindow + CDP (DOM.setFileInputFiles) for non-interactive uploads. + * Fail-fast on blocked indicators (captcha/login). No interactive fallback. + */ +import { BrowserWindow } from 'electron'; +import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js'; + +/** File extensions that denote direct media URLs (mirrors src/lib/media-hosting/direct-url.ts) */ +const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv']; + +function isDirectMediaUrl(url) { + try { + const normalized = url.split('?')[0].toLowerCase(); + return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext)); + } catch { + return false; + } +} + +/** + * Run automated upload for a provider. + * @param {Object} options + * @param {string} options.provider - Provider id (catbox, imgur, postimages) + * @param {string} options.filePath - Absolute path to the file to upload + * @returns {Promise<{ url: string; provider: string }>} + * @throws {Error} On missing recipe, blocked indicators, timeout, or invalid URL + */ +export async function automateUploadMedia(options) { + const { provider, filePath } = options; + const recipe = MEDIA_UPLOAD_RECIPES[provider]; + if (!recipe) { + throw new Error(`No automation recipe for provider: ${provider}`); + } + + let win = null; + try { + win = new BrowserWindow({ + show: false, + webPreferences: { + webSecurity: true, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }); + + await new Promise((resolve, reject) => { + try { + win.webContents.debugger.attach('1.3'); + resolve(); + } catch (e) { + reject(e); + } + }); + + const sendCommand = (method, params = {}) => win.webContents.debugger.sendCommand(method, params); + + await sendCommand('DOM.enable'); + await sendCommand('Page.enable'); + + await new Promise((resolve, reject) => { + win.loadURL(recipe.uploadUrl); + win.webContents.once('did-finish-load', () => { + resolve(); + }); + win.webContents.once('did-fail-load', (_, code, desc) => { + reject(new Error(`Page load failed: ${code} ${desc}`)); + }); + }); + + // Small delay for SPA/JS to settle + await new Promise((r) => setTimeout(r, 1500)); + + const doc = await sendCommand('DOM.getDocument'); + const rootNodeId = doc.root?.nodeId; + if (rootNodeId == null) { + throw new Error('Could not get document root'); + } + + const queryOne = async (selector) => { + const { nodeId } = await sendCommand('DOM.querySelector', { + nodeId: rootNodeId, + selector, + }); + return nodeId || null; + }; + + const checkBlocked = async () => { + for (const sel of recipe.blockedIndicators) { + const nodeId = await queryOne(sel); + if (nodeId) { + return true; + } + } + return false; + }; + + if (await checkBlocked()) { + throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider})`); + } + + let fileInputNodeId = null; + for (const sel of recipe.fileInputSelectorCandidates) { + fileInputNodeId = await queryOne(sel); + if (fileInputNodeId) break; + } + if (!fileInputNodeId) { + throw new Error(`No file input found for ${provider}. Tried: ${recipe.fileInputSelectorCandidates.join(', ')}`); + } + + await sendCommand('DOM.setFileInputFiles', { + nodeId: fileInputNodeId, + files: [filePath], + }); + + let submitNodeId = null; + for (const sel of recipe.submitSelectorCandidates) { + submitNodeId = await queryOne(sel); + if (submitNodeId) break; + } + if (!submitNodeId) { + throw new Error(`No submit button found for ${provider}. Tried: ${recipe.submitSelectorCandidates.join(', ')}`); + } + + const clickNode = async (nodeId) => { + const { model } = await sendCommand('DOM.getBoxModel', { nodeId }); + if (!model?.content) return; + const [x1, y1, x2, y2] = model.content; + const x = (x1 + x2) / 2; + const y = (y1 + y2) / 2; + await sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x, + y, + button: 'left', + clickCount: 1, + }); + await sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x, + y, + button: 'left', + clickCount: 1, + }); + }; + + await sendCommand('Input.enable'); + await clickNode(submitNodeId); + + const extractUrl = async () => { + const { selectorCandidates, attribute } = recipe.successExtractor; + const attr = attribute; + const code = ` + (function() { + const selectors = ${JSON.stringify(selectorCandidates)}; + const attr = ${JSON.stringify(attr)}; + for (const sel of selectors) { + try { + const el = document.querySelector(sel); + if (!el) continue; + let url = ''; + if (attr === 'text') { + url = (el.textContent || '').trim(); + } else if (attr === 'value') { + url = (el.value || el.getAttribute('value') || '').trim(); + } else { + url = (el.getAttribute(attr) || el[attr] || '').trim(); + } + if (url && url.startsWith('http')) return url; + } catch (e) {} + } + return null; + })() + `; + const { result } = await sendCommand('Runtime.evaluate', { + expression: code, + returnByValue: true, + }); + return result?.value ?? null; + }; + + const pollIntervalMs = 500; + const start = Date.now(); + let url = null; + while (Date.now() - start < recipe.timeoutMs) { + if (await checkBlocked()) { + throw new Error(`Provider blocked during upload: captcha or challenge (${provider})`); + } + url = await extractUrl(); + if (url && isDirectMediaUrl(url)) { + break; + } + url = null; + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + + if (!url || !isDirectMediaUrl(url)) { + throw new Error(`Upload timeout or no direct URL extracted for ${provider} (${recipe.timeoutMs}ms)`); + } + + return { url, provider }; + } finally { + if (win && !win.isDestroyed()) { + try { + if (win.webContents?.debugger?.isAttached?.()) { + win.webContents.debugger.detach(); + } + } catch (_) { + /* ignore */ + } + win.destroy(); + win = null; + } + } +} diff --git a/electron/media-upload-recipes.js b/electron/media-upload-recipes.js new file mode 100644 index 00000000..09eba180 --- /dev/null +++ b/electron/media-upload-recipes.js @@ -0,0 +1,52 @@ +/** + * Provider-specific recipes for automated media upload via hidden BrowserWindow. + * Each recipe defines selectors and behavior for DOM-based upload flows. + * Selectors are candidate-based: try each until one matches. Fail fast on blocked indicators. + * + * @typedef {Object} ProviderRecipe + * @property {string} uploadUrl - Full URL of the provider's upload page + * @property {readonly string[]} fileInputSelectorCandidates - CSS selectors for file input (first match wins) + * @property {readonly string[]} submitSelectorCandidates - CSS selectors for submit/upload button + * @property {Object} successExtractor - How to extract the result URL from the page + * @property {readonly string[]} successExtractor.selectorCandidates - Selectors for element containing result URL + * @property {'href'|'src'|'value'|'text'} successExtractor.attribute - Attribute or 'text' for textContent + * @property {readonly string[]} blockedIndicators - Selectors that indicate captcha/login/challenge (fail immediately if present) + * @property {number} timeoutMs - Max time to wait for upload completion + */ + +/** @type {Readonly>} */ +export const MEDIA_UPLOAD_RECIPES = Object.freeze({ + catbox: Object.freeze({ + uploadUrl: 'https://catbox.moe', + fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]']), + submitSelectorCandidates: Object.freeze(['button[type="submit"]', 'input[type="submit"]', '[type="submit"]']), + successExtractor: Object.freeze({ + selectorCandidates: Object.freeze(['a[href*="files.catbox.moe"]', 'input[value*="files.catbox.moe"]', '[class*="result"] a', 'textarea']), + attribute: 'href', + }), + blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '.login-form', '#recaptcha']), + timeoutMs: 30_000, + }), + imgur: Object.freeze({ + uploadUrl: 'https://imgur.com/upload', + fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '[data-file-input]']), + submitSelectorCandidates: Object.freeze(['button[type="submit"]', '[data-action="upload"]', '.upload-btn', '[type="submit"]']), + successExtractor: Object.freeze({ + selectorCandidates: Object.freeze(['a[href*="i.imgur.com"]', 'input[value*="i.imgur.com"]', '[class*="copy-link"] input', '[data-link]']), + attribute: 'href', + }), + blockedIndicators: Object.freeze(['#challenge', '.captcha', '.g-recaptcha', '#recaptcha', '.signin', '.login']), + timeoutMs: 45_000, + }), + postimages: Object.freeze({ + uploadUrl: 'https://postimages.org', + fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '#uploadFile', '.fileinput']), + submitSelectorCandidates: Object.freeze(['button[type="submit"]', '[type="submit"]', '.btn-upload']), + successExtractor: Object.freeze({ + selectorCandidates: Object.freeze(['input[readonly][value*="postimg"]', 'a[href*="i.postimg.cc"]', '[class*="direct-link"]', 'textarea']), + attribute: 'value', + }), + blockedIndicators: Object.freeze(['#challenge', '.captcha', '.g-recaptcha', '#recaptcha']), + timeoutMs: 45_000, + }), +}); diff --git a/electron/preload.mjs b/electron/preload.mjs index 0b1888fa..81ce6ac5 100644 --- a/electron/preload.mjs +++ b/electron/preload.mjs @@ -16,8 +16,9 @@ contextBridge.exposeInMainWorld('defaultMediaIpfsGatewayUrl', 'http://localhost: ipcRenderer.on('plebbit-rpc-auth-key', (event, plebbitRpcAuthKey) => contextBridge.exposeInMainWorld('plebbitRpcAuthKey', plebbitRpcAuthKey)); ipcRenderer.send('get-plebbit-rpc-auth-key'); -contextBridge.exposeInMainWorld('electronApi', { +contextBridge.exposeInMainWorld('electronApi', { isElectron: true, copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text), getPlatform: () => ipcRenderer.invoke('get-platform'), -}); \ No newline at end of file + automateUploadMedia: (options) => ipcRenderer.invoke('automate-upload-media', options), +}); diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index 007b89b7..8acd7ea7 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "لا شيء", "media_hosting_none_tip": "إخفاء زر تحميل الملف من نماذج المنشورات والردود", "media_hosting": "استضافة الوسائط", - "advanced_settings": "الإعدادات المتقدمة" + "advanced_settings": "الإعدادات المتقدمة", + "upload_failed_all_providers": "فشل جميع مزودي الخدمة", + "upload_failed_preferred_guidance": "اختر مزود خدمة مختلفًا أو اضبط على عشوائي.", + "media_hosting_random": "عشوائي", + "media_hosting_preferred": "مزود مفضل", + "media_hosting_preferred_provider_label": "اختر مزود الخدمة", + "media_hosting_preferred_failure_guidance": "إذا فشل مزودك المفضل، ستجرب التطبيق مزودين آخرين (وضع عشوائي) أو يعرض خطأ (وضع مفضل).", + "media_hosting_all_providers_failed_guidance": "فشل جميع مزودي التحميل. حاول مرة أخرى أو ألصق رابطًا يدويًا.", + "upload_not_supported_web_before_link": "رفع الوسائط غير مدعوم من متصفحك بسبب قيود تقنية (CORS). إنه مدعوم في تطبيق 5chan لـ Android وتطبيق سطح المكتب لـ Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "تحميل من صفحة الإصدار الرسمية" } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index a803c49f..580d14a9 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "কোনো নয়", "media_hosting_none_tip": "পোস্ট এবং রিপ্লাই ফর্ম থেকে ফাইল আপলোড বাটন লুকায়", "media_hosting": "মিডিয়া হোস্টিং", - "advanced_settings": "উন্নত সেটিংস" + "advanced_settings": "উন্নত সেটিংস", + "upload_failed_all_providers": "সমস্ত প্রদানকারী ব্যর্থ হয়েছে", + "upload_failed_preferred_guidance": "একটি ভিন্ন প্রদানকারী নির্বাচন করুন বা র‍্যান্ডমে সেট করুন।", + "media_hosting_random": "এলোমেলো", + "media_hosting_preferred": "পছন্দের প্রদানকারী", + "media_hosting_preferred_provider_label": "প্রদানকারী নির্বাচন করুন", + "media_hosting_preferred_failure_guidance": "যদি আপনার পছন্দের প্রদানকারী ব্যর্থ হয়, অ্যাপটি অন্যান্য প্রদানকারীদের চেষ্টা করবে (এলোমেলো মোড) বা একটি ত্রুটি দেখাবে (পছন্দের মোড)।", + "media_hosting_all_providers_failed_guidance": "সমস্ত আপলোড প্রদানকারী ব্যর্থ। আবার চেষ্টা করুন বা লিঙ্ক ম্যানুয়ালি পেস্ট করুন।", + "upload_not_supported_web_before_link": "প্রযুক্তিগত সীমাবদ্ধতার কারণে (CORS) আপনার ব্রাউজার মিডিয়া আপলোড সমর্থন করে না। এটি 5chan অ্যান্ড্রয়েড অ্যাপ এবং Mac/Linux/Windows ডেস্কটপ অ্যাপে সমর্থিত।", + "upload_not_supported_web_link_text": "অফিসিয়াল রিলিজ পৃষ্ঠা থেকে ডাউনলোড করুন" } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 693a5fc1..f80f496e 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Žádné", "media_hosting_none_tip": "Skrývá tlačítko pro nahrávání souborů z formulářů příspěvků a odpovědí", "media_hosting": "Hostování médií", - "advanced_settings": "Pokročilé nastavení" + "advanced_settings": "Pokročilé nastavení", + "upload_failed_all_providers": "Všichni poskytovatelé selhali", + "upload_failed_preferred_guidance": "Zvolte jiného poskytovatele nebo nastavte náhodné.", + "media_hosting_random": "Náhodné", + "media_hosting_preferred": "Upřednostňovaný poskytovatel", + "media_hosting_preferred_provider_label": "Zvolit poskytovatele", + "media_hosting_preferred_failure_guidance": "Pokud váš preferovaný poskytovatel selže, aplikace vyzkouší další poskytovatele (režim náhodný) nebo zobrazí chybu (režim preferovaný).", + "media_hosting_all_providers_failed_guidance": "Všichni poskytovatelé nahrávání selhali. Zkuste to znovu nebo vložte odkaz ručně.", + "upload_not_supported_web_before_link": "Váš prohlížeč z důvodu technických omezení (CORS) nepodporuje nahrávání médií. Je podporováno v aplikaci 5chan pro Android a desktopové aplikaci pro Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Stáhnout z oficiální stránky vydání" } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 46200d59..d4910acf 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Ingen", "media_hosting_none_tip": "Skjuler filoverføringsknappen fra post- og svarformularer", "media_hosting": "Mediahosting", - "advanced_settings": "Avancerede indstillinger" + "advanced_settings": "Avancerede indstillinger", + "upload_failed_all_providers": "Alle udbydere mislykkedes", + "upload_failed_preferred_guidance": "Vælg en anden udbyder eller sæt til tilfældig.", + "media_hosting_random": "Tilfældig", + "media_hosting_preferred": "Foretrukket udbyder", + "media_hosting_preferred_provider_label": "Vælg udbyder", + "media_hosting_preferred_failure_guidance": "Hvis din foretrukne udbyder fejler, vil appen prøve andre udbydere (tilfældig tilstand) eller vise en fejl (foretrukket tilstand).", + "media_hosting_all_providers_failed_guidance": "Alle upload-udbydere mislykkedes. Prøv igen eller indsæt et link manuelt.", + "upload_not_supported_web_before_link": "Din browser understøtter ikke upload af medier på grund af tekniske begrænsninger (CORS). Det understøttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen.", + "upload_not_supported_web_link_text": "Download fra den officielle udgivelsesside" } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 92e5723e..eb1a1dc9 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Keine", "media_hosting_none_tip": "Verbirgt die Datei-Upload-Schaltfläche aus Post- und Antwortformularen", "media_hosting": "Medienhosting", - "advanced_settings": "Erweiterte Einstellungen" + "advanced_settings": "Erweiterte Einstellungen", + "upload_failed_all_providers": "Alle Anbieter sind fehlgeschlagen", + "upload_failed_preferred_guidance": "Wählen Sie einen anderen Anbieter oder setzen Sie auf Zufall.", + "media_hosting_random": "Zufällig", + "media_hosting_preferred": "Bevorzugter Anbieter", + "media_hosting_preferred_provider_label": "Anbieter wählen", + "media_hosting_preferred_failure_guidance": "Wenn Ihr bevorzugter Anbieter fehlschlägt, versucht die App andere Anbieter (Zufallsmodus) oder zeigt einen Fehler an (Bevorzugter Modus).", + "media_hosting_all_providers_failed_guidance": "Alle Upload-Anbieter sind fehlgeschlagen. Versuchen Sie es erneut oder fügen Sie einen Link manuell ein.", + "upload_not_supported_web_before_link": "Das Hochladen von Medien wird von Ihrem Browser aufgrund technischer Einschränkungen (CORS) nicht unterstützt. Es wird in der 5chan-Android-App und der Mac/Linux/Windows-Desktop-App unterstützt.", + "upload_not_supported_web_link_text": "Von der offiziellen Release-Seite herunterladen" } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index ca588d60..8186cf01 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Κανένα", "media_hosting_none_tip": "Αποκρύπτει το κουμπί μεταφόρτωσης αρχείου από τις φόρμες δημοσίευσης και απάντησης", "media_hosting": "Φιλοξενία Πολυμέσων", - "advanced_settings": "Προηγμένες ρυθμίσεις" + "advanced_settings": "Προηγμένες ρυθμίσεις", + "upload_failed_all_providers": "Όλοι οι πάροχοι απέτυχαν", + "upload_failed_preferred_guidance": "Επιλέξτε διαφορετικό πάροχο ή ορίστε τυχαιότητα.", + "media_hosting_random": "Τυχαίο", + "media_hosting_preferred": "Προτιμώμενος πάροχος", + "media_hosting_preferred_provider_label": "Επιλέξτε πάροχο", + "media_hosting_preferred_failure_guidance": "Αν ο προτιμώμενος πάροχος σας αποτύχει, η εφαρμογή θα δοκιμάσει άλλους πάροχους (τυχαία λειτουργία) ή θα εμφανίσει σφάλμα (προτιμώμενη λειτουργία).", + "media_hosting_all_providers_failed_guidance": "Όλοι οι πάροχοι μεταφόρτωσης απέτυχαν. Δοκιμάστε ξανά ή επικολλήστε έναν σύνδεσμο χειροκίνητα.", + "upload_not_supported_web_before_link": "Η μεταφόρτωση πολυμέσων δεν υποστηρίζεται από το πρόγραμμα περιήγησής σας λόγω τεχνικών περιορισμών (CORS). Υποστηρίζεται στην εφαρμογή 5chan για Android και την εφαρμογή επιφάνειας εργασίας Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Λήψη από τη σελίδα επίσημης έκδοσης" } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index e9eb7b89..a32bfac1 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -283,9 +283,18 @@ "vote_button_unavailable_outro": "The highest-voted /{{boardIdentifier}}/ becomes the official /{{boardIdentifier}}/.", "upload_failed": "Upload failed", "upload_not_supported_web": "Uploading media isn't supported by your browser due to technical limitations (CORS). It is supported in the 5chan Android app and Mac/Linux/Windows desktop app. Download from the official release page: https://github.com/bitsocialhq/5chan/releases/latest", + "upload_not_supported_web_before_link": "Uploading media isn't supported by your browser due to technical limitations (CORS). It is supported in the 5chan Android app and Mac/Linux/Windows desktop app.", + "upload_not_supported_web_link_text": "Download from the official release page", "file": "File", "media_hosting_none": "None", "media_hosting_none_tip": "Hides the file upload button from post and reply forms", "media_hosting": "Media Hosting", - "advanced_settings": "Advanced settings" + "advanced_settings": "Advanced settings", + "upload_failed_all_providers": "All providers failed", + "upload_failed_preferred_guidance": "Choose a different provider or set to random.", + "media_hosting_random": "Random", + "media_hosting_preferred": "Preferred provider", + "media_hosting_preferred_provider_label": "Choose provider", + "media_hosting_preferred_failure_guidance": "If your preferred provider fails, the app will try other providers (Random mode) or show an error (Preferred mode).", + "media_hosting_all_providers_failed_guidance": "All upload providers failed. Try again or paste a link manually." } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index f391929a..3a665ad7 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Ninguno", "media_hosting_none_tip": "Oculta el botón de carga de archivos de los formularios de publicación y respuesta", "media_hosting": "Alojamiento de Medios", - "advanced_settings": "Configuración avanzada" + "advanced_settings": "Configuración avanzada", + "upload_failed_all_providers": "Todos los proveedores fallaron", + "upload_failed_preferred_guidance": "Elige un proveedor diferente o configura en aleatorio.", + "media_hosting_random": "Aleatorio", + "media_hosting_preferred": "Proveedor preferido", + "media_hosting_preferred_provider_label": "Elegir proveedor", + "media_hosting_preferred_failure_guidance": "Si tu proveedor preferido falla, la app intentará otros proveedores (modo aleatorio) o mostrará un error (modo preferido).", + "media_hosting_all_providers_failed_guidance": "Todos los proveedores de subida fallaron. Inténtalo de nuevo o pega un enlace manualmente.", + "upload_not_supported_web_before_link": "La subida de medios no está soportada por tu navegador debido a limitaciones técnicas (CORS). Sí está soportada en la app 5chan para Android y la app de escritorio para Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Descargar desde la página de release oficial" } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index 5d8431e3..b59306bf 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "هیچ کدام", "media_hosting_none_tip": "دکمه بارگذاری فایل را از فرم‌های پست و پاسخ مخفی می‌کند", "media_hosting": "میزبانی رسانه", - "advanced_settings": "تنظیمات پیشرفته" + "advanced_settings": "تنظیمات پیشرفته", + "upload_failed_all_providers": "همه ارائه‌دهندگان ناموفق بودند", + "upload_failed_preferred_guidance": "ارائه‌دهنده دیگری انتخاب کنید یا روی تصادفی تنظیم کنید.", + "media_hosting_random": "تصادفی", + "media_hosting_preferred": "ارائه‌دهنده ترجیحی", + "media_hosting_preferred_provider_label": "ارائه‌دهنده را انتخاب کنید", + "media_hosting_preferred_failure_guidance": "اگر ارائه‌دهنده ترجیحی شما ناموفق باشد، برنامه ارائه‌دهندگان دیگر را امتحان می‌کند (حالت تصادفی) یا خطا نمایش می‌دهد (حالت ترجیحی).", + "media_hosting_all_providers_failed_guidance": "همه ارائه‌دهندگان آپلود ناموفق بودند. دوباره تلاش کنید یا لینک را دستی جایگذاری کنید.", + "upload_not_supported_web_before_link": "آپلود رسانه به دلیل محدودیت‌های فنی (CORS) توسط مرورگر شما پشتیبانی نمی‌شود. در اپلیکیشن 5chan اندروید و اپلیکیشن دسکتاپ Mac/Linux/Windows پشتیبانی می‌شود.", + "upload_not_supported_web_link_text": "دانلود از صفحه انتشار رسمی" } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 07749756..941f2c18 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Ei mikään", "media_hosting_none_tip": "Piilottaa tiedostonlatausnapin post- ja vastauslomakkeista", "media_hosting": "Mediahosting", - "advanced_settings": "Lisäasetukset" + "advanced_settings": "Lisäasetukset", + "upload_failed_all_providers": "Kaikki palveluntarjoajat epäonnistuivat", + "upload_failed_preferred_guidance": "Valitse toinen palveluntarjoaja tai aseta satunnaiseksi.", + "media_hosting_random": "Satunnainen", + "media_hosting_preferred": "Suosikki palveluntarjoaja", + "media_hosting_preferred_provider_label": "Valitse palveluntarjoaja", + "media_hosting_preferred_failure_guidance": "Jos suosikki palveluntarjoajasi epäonnistuu, sovellus kokeilee muita palveluntarjoajia (satunnainen tila) tai näyttää virheen (suosikki tila).", + "media_hosting_all_providers_failed_guidance": "Kaikki latauspalveluntarjoajat epäonnistuivat. Yritä uudelleen tai liitä linkki manuaalisesti.", + "upload_not_supported_web_before_link": "Medioiden lataus ei ole tuettu selaimessasi teknisten rajoitusten vuoksi (CORS). Se on tuettu 5chan Android-sovelluksessa ja Mac/Linux/Windows-työpöytäsovelluksessa.", + "upload_not_supported_web_link_text": "Lataa viralliselta julkaisusivulta" } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 224f6563..27ec33e1 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Wala", "media_hosting_none_tip": "Nagsasagawa ng pindot sa pag-upload ng file mula sa mga form ng post at reply", "media_hosting": "Media Hosting", - "advanced_settings": "Mga advanced na setting" + "advanced_settings": "Mga advanced na setting", + "upload_failed_all_providers": "Lahat ng mga provider ay nabigo", + "upload_failed_preferred_guidance": "Pumili ng ibang provider o itakda sa random.", + "media_hosting_random": "Random", + "media_hosting_preferred": "Gustong provider", + "media_hosting_preferred_provider_label": "Pumili ng provider", + "media_hosting_preferred_failure_guidance": "Kung nabigo ang iyong gustong provider, susubukan ng app ang iba pang provider (Random mode) o magpapakita ng error (Preferred mode).", + "media_hosting_all_providers_failed_guidance": "Lahat ng mga upload provider ay nabigo. Subukan muli o i-paste ang link nang manu-mano.", + "upload_not_supported_web_before_link": "Ang pag-upload ng media ay hindi sinusuportahan ng iyong browser dahil sa mga teknikal na limitasyon (CORS). Ito ay sinusuportahan sa 5chan Android app at Mac/Linux/Windows desktop app.", + "upload_not_supported_web_link_text": "I-download mula sa opisyal na release page" } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 1fa5691f..e1b65ccb 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Aucun", "media_hosting_none_tip": "Masque le bouton de téléchargement de fichiers des formulaires de publication et de réponse", "media_hosting": "Hébergement Multimédia", - "advanced_settings": "Paramètres avancés" + "advanced_settings": "Paramètres avancés", + "upload_failed_all_providers": "Tous les fournisseurs ont échoué", + "upload_failed_preferred_guidance": "Choisissez un autre fournisseur ou réglez sur aléatoire.", + "media_hosting_random": "Aléatoire", + "media_hosting_preferred": "Fournisseur préféré", + "media_hosting_preferred_provider_label": "Choisir le fournisseur", + "media_hosting_preferred_failure_guidance": "Si votre fournisseur préféré échoue, l'app essaiera d'autres fournisseurs (mode aléatoire) ou affichera une erreur (mode préféré).", + "media_hosting_all_providers_failed_guidance": "Tous les fournisseurs de téléchargement ont échoué. Réessayez ou collez un lien manuellement.", + "upload_not_supported_web_before_link": "Le téléchargement de médias n'est pas pris en charge par votre navigateur en raison de limitations techniques (CORS). Il est pris en charge dans l'application 5chan Android et l'application de bureau Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Télécharger depuis la page de version officielle" } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 6edd8bdf..81e68390 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "ללא", "media_hosting_none_tip": "מסתיר את כפתור העלאת הקבצים מטפסי פוסט ותשובה", "media_hosting": "אירוח מדיה", - "advanced_settings": "הגדרות מתקדמות" + "advanced_settings": "הגדרות מתקדמות", + "upload_failed_all_providers": "כל הספקים נכשלו", + "upload_failed_preferred_guidance": "בחר ספק אחר או הגדר לאקראי.", + "media_hosting_random": "אקראי", + "media_hosting_preferred": "ספק מועדף", + "media_hosting_preferred_provider_label": "בחר ספק", + "media_hosting_preferred_failure_guidance": "אם הספק המועדף שלך נכשל, האפליקציה תנסה ספקים אחרים (מצב אקראי) או תציג שגיאה (מצב מועדף).", + "media_hosting_all_providers_failed_guidance": "כל ספקי ההעלאה נכשלו. נסה שוב או הדבק קישור ידנית.", + "upload_not_supported_web_before_link": "העלאת מדיה אינה נתמכת על ידי הדפדפן שלך בשל מגבלות טכניות (CORS). זה נתמך באפליקציית 5chan לאנדרואיד ובאפליקציית הדסקטופ ל-Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "הורד מהדף השחרור הרשמי" } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index a9d7e147..3f2d190b 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "कोई नहीं", "media_hosting_none_tip": "पोस्ट और रिप्लाई फॉर्म से फ़ाइल अपलोड बटन को छिपाता है", "media_hosting": "मीडिया होस्टिंग", - "advanced_settings": "उन्नत सेटिंग्स" + "advanced_settings": "उन्नत सेटिंग्स", + "upload_failed_all_providers": "सभी प्रदाता विफल हो गए", + "upload_failed_preferred_guidance": "कोई अलग प्रदाता चुनें या यादृच्छिक पर सेट करें।", + "media_hosting_random": "यादृच्छिक", + "media_hosting_preferred": "पसंदीदा प्रदाता", + "media_hosting_preferred_provider_label": "प्रदाता चुनें", + "media_hosting_preferred_failure_guidance": "यदि आपका पसंदीदा प्रदाता विफल होता है, ऐप अन्य प्रदाताओं को आज़माएगा (यादृच्छिक मोड) या त्रुटि दिखाएगा (पसंदीदा मोड)।", + "media_hosting_all_providers_failed_guidance": "सभी अपलोड प्रदाता विफल हो गए। पुनः प्रयास करें या लिंक मैन्युअल रूप से पेस्ट करें।", + "upload_not_supported_web_before_link": "तकनीकी सीमाओं (CORS) के कारण आपका ब्राउज़र मीडिया अपलोड का समर्थन नहीं करता। यह 5chan एंड्रॉइड ऐप और Mac/Linux/Windows डेस्कटॉप ऐप में समर्थित है।", + "upload_not_supported_web_link_text": "आधिकारिक रिलीज़ पेज से डाउनलोड करें" } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 8c65ab75..9441cfef 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Nincs", "media_hosting_none_tip": "Elrejti a fájlfeltöltés gombot a poszt- és válaszűrlapokból", "media_hosting": "Médiahosting", - "advanced_settings": "Haladó beállítások" + "advanced_settings": "Haladó beállítások", + "upload_failed_all_providers": "Minden szolgáltató sikertelen volt", + "upload_failed_preferred_guidance": "Válasszon másik szolgáltatót vagy állítsa véletlenszerűre.", + "media_hosting_random": "Véletlenszerű", + "media_hosting_preferred": "Előnyben részesített szolgáltató", + "media_hosting_preferred_provider_label": "Szolgáltató kiválasztása", + "media_hosting_preferred_failure_guidance": "Ha az előnyben részesített szolgáltató sikertelen, az alkalmazás más szolgáltatókat próbál (véletlenszerű mód) vagy hibát jelenít meg (előnyben részesített mód).", + "media_hosting_all_providers_failed_guidance": "Minden feltöltési szolgáltató sikertelen volt. Próbálja újra vagy illessze be a linket manuálisan.", + "upload_not_supported_web_before_link": "A böngésző nem támogatja a média feltöltését technikai korlátozások miatt (CORS). A 5chan Android alkalmazásban és a Mac/Linux/Windows asztali alkalmazásban támogatott.", + "upload_not_supported_web_link_text": "Letöltés a hivatalos kiadási oldalról" } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 475e598d..2dc2337b 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Tidak ada", "media_hosting_none_tip": "Menyembunyikan tombol unggah file dari formulir posting dan balasan", "media_hosting": "Hosting Media", - "advanced_settings": "Pengaturan lanjutan" + "advanced_settings": "Pengaturan lanjutan", + "upload_failed_all_providers": "Semua penyedia gagal", + "upload_failed_preferred_guidance": "Pilih penyedia lain atau atur ke acak.", + "media_hosting_random": "Acak", + "media_hosting_preferred": "Penyedia pilihan", + "media_hosting_preferred_provider_label": "Pilih penyedia", + "media_hosting_preferred_failure_guidance": "Jika penyedia pilihan Anda gagal, aplikasi akan mencoba penyedia lain (mode acak) atau menampilkan error (mode preferensi).", + "media_hosting_all_providers_failed_guidance": "Semua penyedia unggahan gagal. Coba lagi atau tempel link secara manual.", + "upload_not_supported_web_before_link": "Upload media tidak didukung oleh browser Anda karena batasan teknis (CORS). Didukung di aplikasi 5chan Android dan aplikasi desktop Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Unduh dari halaman rilis resmi" } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 55dae7b4..b2a841d2 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Nessuno", "media_hosting_none_tip": "Nasconde il pulsante di caricamento del file dai moduli di post e risposta", "media_hosting": "Hosting Media", - "advanced_settings": "Impostazioni avanzate" + "advanced_settings": "Impostazioni avanzate", + "upload_failed_all_providers": "Tutti i provider sono falliti", + "upload_failed_preferred_guidance": "Scegli un provider diverso o imposta su casuale.", + "media_hosting_random": "Casuale", + "media_hosting_preferred": "Provider preferito", + "media_hosting_preferred_provider_label": "Scegli provider", + "media_hosting_preferred_failure_guidance": "Se il provider preferito fallisce, l'app proverà altri provider (modalità casuale) o mostrerà un errore (modalità preferita).", + "media_hosting_all_providers_failed_guidance": "Tutti i provider di caricamento sono falliti. Riprova o incolla un link manualmente.", + "upload_not_supported_web_before_link": "Il caricamento di file multimediali non è supportato dal tuo browser a causa di limitazioni tecniche (CORS). È supportato nell'app 5chan per Android e nell'app desktop per Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Scarica dalla pagina di release ufficiale" } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index 2e3aeecc..312a3349 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "なし", "media_hosting_none_tip": "投稿とコメントフォームのファイルアップロードボタンを非表示にします", "media_hosting": "メディアホスティング", - "advanced_settings": "詳細設定" + "advanced_settings": "詳細設定", + "upload_failed_all_providers": "すべてのプロバイダーが失敗しました", + "upload_failed_preferred_guidance": "別のプロバイダーを選択するか、ランダムに設定してください。", + "media_hosting_random": "ランダム", + "media_hosting_preferred": "優先プロバイダー", + "media_hosting_preferred_provider_label": "プロバイダーを選択", + "media_hosting_preferred_failure_guidance": "優先プロバイダーが失敗した場合、アプリは他のプロバイダーを試行します(ランダムモード)またはエラーを表示します(優先モード)。", + "media_hosting_all_providers_failed_guidance": "すべてのアップロードプロバイダーが失敗しました。もう一度お試しいただくか、リンクを手動で貼り付けてください。", + "upload_not_supported_web_before_link": "技術的な制限(CORS)のため、お使いのブラウザではメディアのアップロードがサポートされていません。5chan AndroidアプリとMac/Linux/Windowsデスクトップアプリではサポートされています。", + "upload_not_supported_web_link_text": "公式リリースページからダウンロード" } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 5b66ada6..8aa7996c 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "없음", "media_hosting_none_tip": "게시물 및 댓글 양식에서 파일 업로드 버튼을 숨깁니다", "media_hosting": "미디어 호스팅", - "advanced_settings": "고급 설정" + "advanced_settings": "고급 설정", + "upload_failed_all_providers": "모든 제공업체가 실패했습니다", + "upload_failed_preferred_guidance": "다른 제공업체를 선택하거나 무작위로 설정하세요.", + "media_hosting_random": "무작위", + "media_hosting_preferred": "선호하는 제공업체", + "media_hosting_preferred_provider_label": "제공업체 선택", + "media_hosting_preferred_failure_guidance": "선호하는 제공업체가 실패하면 앱이 다른 제공업체를 시도합니다(무작위 모드) 또는 오류를 표시합니다(선호 모드).", + "media_hosting_all_providers_failed_guidance": "모든 업로드 제공업체가 실패했습니다. 다시 시도하거나 링크를 수동으로 붙여넣으세요.", + "upload_not_supported_web_before_link": "기술적 제한(CORS)으로 인해 브라우저에서는 미디어 업로드를 지원하지 않습니다. 5chan Android 앱과 Mac/Linux/Windows 데스크톱 앱에서는 지원됩니다.", + "upload_not_supported_web_link_text": "공식 출시 페이지에서 다운로드" } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index 58eb9272..096cb02c 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "काहीही नाही", "media_hosting_none_tip": "पोस्ट आणि उत्तर फॉर्मवरून फाइल अपलोड बटन लपवते", "media_hosting": "मीडिया होस्टिंग", - "advanced_settings": "प्रगत सेटिंग्ज" + "advanced_settings": "प्रगत सेटिंग्ज", + "upload_failed_all_providers": "सर्व प्रदाते अयशस्वी झाले", + "upload_failed_preferred_guidance": "वेगळा प्रदाता निवडा किंवा यादृच्छिक वर सेट करा.", + "media_hosting_random": "यादृच्छिक", + "media_hosting_preferred": "पसंतीचा प्रदाता", + "media_hosting_preferred_provider_label": "प्रदाता निवडा", + "media_hosting_preferred_failure_guidance": "जर तुमचा पसंतीचा प्रदाता अयशस्वी झाला, तर अॅप इतर प्रदात्यांना वापरेल (यादृच्छिक मोड) किंवा त्रुटी दर्शवेल (पसंतीचा मोड).", + "media_hosting_all_providers_failed_guidance": "सर्व अपलोड प्रदाते अयशस्वी झाले. पुन्हा प्रयत्न करा किंवा लिंक स्वहस्ते पेस्ट करा.", + "upload_not_supported_web_before_link": "तांत्रिक मर्यादांमुळे (CORS) तुमचा ब्राउझर मीडिया अपलोड समर्थित नाही. ते 5chan Android अॅप आणि Mac/Linux/Windows डेस्कटॉप अॅपमध्ये समर्थित आहे.", + "upload_not_supported_web_link_text": "अधिकृत रिलीझ पेजवरून डाउनलोड करा" } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index d95c306b..97a73977 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Geen", "media_hosting_none_tip": "Verbergt de knop voor bestandsupload uit post- en antwoordformulieren", "media_hosting": "Mediahosting", - "advanced_settings": "Geavanceerde instellingen" + "advanced_settings": "Geavanceerde instellingen", + "upload_failed_all_providers": "Alle providers zijn mislukt", + "upload_failed_preferred_guidance": "Kies een andere provider of zet op willekeurig.", + "media_hosting_random": "Willekeurig", + "media_hosting_preferred": "Voorkeursprovider", + "media_hosting_preferred_provider_label": "Provider kiezen", + "media_hosting_preferred_failure_guidance": "Als uw voorkeursprovider faalt, probeert de app andere providers (willekeurige modus) of toont een fout (geprefereerde modus).", + "media_hosting_all_providers_failed_guidance": "Alle upload-providers zijn mislukt. Probeer opnieuw of plak een link handmatig.", + "upload_not_supported_web_before_link": "Mediabestanden uploaden wordt niet ondersteund door uw browser vanwege technische beperkingen (CORS). Het wordt ondersteund in de 5chan Android-app en de Mac/Linux/Windows desktop-app.", + "upload_not_supported_web_link_text": "Downloaden van de officiële releasepagina" } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index 5ac96809..7b4cd045 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Ingen", "media_hosting_none_tip": "Skjuler filoverføringknappen fra post- og svarskjemaer", "media_hosting": "Mediahosting", - "advanced_settings": "Avanserte innstillinger" + "advanced_settings": "Avanserte innstillinger", + "upload_failed_all_providers": "Alle leverandører mislyktes", + "upload_failed_preferred_guidance": "Velg en annen leverandør eller sett til tilfeldig.", + "media_hosting_random": "Tilfeldig", + "media_hosting_preferred": "Foretrukket leverandør", + "media_hosting_preferred_provider_label": "Velg leverandør", + "media_hosting_preferred_failure_guidance": "Hvis din foretrukne leverandør feiler, vil appen prøve andre leverandører (tilfeldig modus) eller vise en feil (foretrukket modus).", + "media_hosting_all_providers_failed_guidance": "Alle opplastingsleverandører mislyktes. Prøv igjen eller lim inn en lenke manuelt.", + "upload_not_supported_web_before_link": "Opplasting av medier støttes ikke av nettleseren din på grunn av tekniske begrensninger (CORS). Det støttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen.", + "upload_not_supported_web_link_text": "Last ned fra den offisielle utgivelsessiden" } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index 91c3e9f4..a2437c9b 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Brak", "media_hosting_none_tip": "Ukrywa przycisk przesyłania pliku z formularzy postów i odpowiedzi", "media_hosting": "Hosting mediów", - "advanced_settings": "Ustawienia zaawansowane" + "advanced_settings": "Ustawienia zaawansowane", + "upload_failed_all_providers": "Wszyscy dostawcy zawiedli", + "upload_failed_preferred_guidance": "Wybierz innego dostawcę lub ustaw na losowy.", + "media_hosting_random": "Losowy", + "media_hosting_preferred": "Preferowany dostawca", + "media_hosting_preferred_provider_label": "Wybierz dostawcę", + "media_hosting_preferred_failure_guidance": "Jeśli preferowany dostawca zawiedzie, aplikacja spróbuje innych dostawców (tryb losowy) lub wyświetli błąd (tryb preferowany).", + "media_hosting_all_providers_failed_guidance": "Wszyscy dostawcy przesyłania zawiedli. Spróbuj ponownie lub wklej link ręcznie.", + "upload_not_supported_web_before_link": "Przesyłanie multimediów nie jest obsługiwane przez Twoją przeglądarkę z powodu ograniczeń technicznych (CORS). Jest obsługiwane w aplikacji 5chan na Androida i aplikacji komputerowej Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Pobierz ze strony oficjalnego wydania" } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index 9efa431d..d7fae8b7 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Nenhum", "media_hosting_none_tip": "Oculta o botão de envio de arquivo dos formulários de postagem e resposta", "media_hosting": "Hospedagem de Mídia", - "advanced_settings": "Configurações avançadas" + "advanced_settings": "Configurações avançadas", + "upload_failed_all_providers": "Todos os provedores falharam", + "upload_failed_preferred_guidance": "Escolha um provedor diferente ou defina como aleatório.", + "media_hosting_random": "Aleatório", + "media_hosting_preferred": "Provedor preferido", + "media_hosting_preferred_provider_label": "Escolher provedor", + "media_hosting_preferred_failure_guidance": "Se o provedor preferido falhar, o app tentará outros provedores (modo aleatório) ou mostrará um erro (modo preferido).", + "media_hosting_all_providers_failed_guidance": "Todos os provedores de envio falharam. Tente novamente ou cole um link manualmente.", + "upload_not_supported_web_before_link": "O envio de mídia não é suportado pelo seu navegador devido a limitações técnicas (CORS). É suportado no aplicativo 5chan para Android e no aplicativo de desktop Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Baixar da página de lançamento oficial" } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 6ee9e71d..7ae650fa 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Niciunul", "media_hosting_none_tip": "Ascunde butonul de încărcare a fișierelor din formularele de postare și răspuns", "media_hosting": "Găzduire Media", - "advanced_settings": "Setări avansate" + "advanced_settings": "Setări avansate", + "upload_failed_all_providers": "Toți furnizorii au eșuat", + "upload_failed_preferred_guidance": "Alegeți un furnizor diferit sau setați la aleatoriu.", + "media_hosting_random": "Aleatoriu", + "media_hosting_preferred": "Furnizor preferat", + "media_hosting_preferred_provider_label": "Alegeți furnizorul", + "media_hosting_preferred_failure_guidance": "Dacă furnizorul preferat eșuează, aplicația va încerca alți furnizori (mod aleatoriu) sau va afișa o eroare (mod preferat).", + "media_hosting_all_providers_failed_guidance": "Toți furnizorii de încărcare au eșuat. Încercați din nou sau lipiți un link manual.", + "upload_not_supported_web_before_link": "Încărcarea mediilor nu este acceptată de browserul dvs. din cauza limitărilor tehnice (CORS). Este acceptată în aplicația 5chan pentru Android și aplicația de desktop Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Descărcare de pe pagina de lansare oficială" } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index 8c1021c9..2bd3d9c4 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Нет", "media_hosting_none_tip": "Скрывает кнопку загрузки файла из форм постов и ответов", "media_hosting": "Хостинг медиа", - "advanced_settings": "Расширенные настройки" + "advanced_settings": "Расширенные настройки", + "upload_failed_all_providers": "Все провайдеры не справились", + "upload_failed_preferred_guidance": "Выберите другого провайдера или установите случайный.", + "media_hosting_random": "Случайный", + "media_hosting_preferred": "Предпочтительный провайдер", + "media_hosting_preferred_provider_label": "Выбрать провайдера", + "media_hosting_preferred_failure_guidance": "Если предпочтительный провайдер не сработает, приложение попробует других провайдеров (случайный режим) или покажет ошибку (режим предпочтительный).", + "media_hosting_all_providers_failed_guidance": "Все провайдеры загрузки не справились. Попробуйте снова или вставьте ссылку вручную.", + "upload_not_supported_web_before_link": "Загрузка медиа не поддерживается вашим браузером из-за технических ограничений (CORS). Поддерживается в приложении 5chan для Android и десктопном приложении для Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Скачать со страницы официального релиза" } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 7ee6270d..6b442b34 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Asnjë", "media_hosting_none_tip": "Fsheh butonin e ngarkimit të skedarit nga formularët e postimit dhe përgjigjes", "media_hosting": "Hostimi i Mediave", - "advanced_settings": "Cilësimet e avancuara" + "advanced_settings": "Cilësimet e avancuara", + "upload_failed_all_providers": "Të gjithë ofruesit dështuan", + "upload_failed_preferred_guidance": "Zgjidhni një ofrues tjetër ose vendosni në rastësishëm.", + "media_hosting_random": "Rastësishëm", + "media_hosting_preferred": "Ofrues i preferuar", + "media_hosting_preferred_provider_label": "Zgjidhni ofruesin", + "media_hosting_preferred_failure_guidance": "Nëse ofruesi juaj i preferuar dështon, aplikacioni do të provojë ofrues të tjerë (modalitet i rastësishëm) ose do të shfaqë një gabim (modalitet i preferuar).", + "media_hosting_all_providers_failed_guidance": "Të gjithë ofruesit e ngarkimit dështuan. Provoni përsëri ose ngjisni një lidhje manualisht.", + "upload_not_supported_web_before_link": "Ngarkimi i mediave nuk mbështetet nga shfletuesi juaj për shkak të kufizimeve teknike (CORS). Mbështetet në aplikacionin 5chan për Android dhe aplikacionin e desktopit Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Shkarkoni nga faqja e zyrës së publikimit" } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 39c9c74a..3eb64e89 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Ingen", "media_hosting_none_tip": "Döljer knappen för filöverföring från inläggs- och svarsformulär", "media_hosting": "Mediahosting", - "advanced_settings": "Avancerade inställningar" + "advanced_settings": "Avancerade inställningar", + "upload_failed_all_providers": "Alla leverantörer misslyckades", + "upload_failed_preferred_guidance": "Välj en annan leverantör eller sätt till slumpmässig.", + "media_hosting_random": "Slumpmässig", + "media_hosting_preferred": "Föredragen leverantör", + "media_hosting_preferred_provider_label": "Välj leverantör", + "media_hosting_preferred_failure_guidance": "Om din föredragna leverantör misslyckas kommer appen att prova andra leverantörer (slumpmässigt läge) eller visa ett fel (föredraget läge).", + "media_hosting_all_providers_failed_guidance": "Alla uppladdningsleverantörer misslyckades. Försök igen eller klistra in en länk manuellt.", + "upload_not_supported_web_before_link": "Uppladdning av media stöds inte av din webbläsare på grund av tekniska begränsningar (CORS). Det stöds i 5chan Android-appen och Mac/Linux/Windows skrivbordsappen.", + "upload_not_supported_web_link_text": "Ladda ner från den officiella utgivningssidan" } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 736a4f7c..8bc8b5ae 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "ఏదీ లేదు", "media_hosting_none_tip": "పోస్ట్ మరియు ప్రతిస్పందన ఫారమ్‌ల నుండి ఫైల్ అప్‌లోడ్ బటన్‌ను దాచిపెడుతుంది", "media_hosting": "మీడియా హోస్టింగ్", - "advanced_settings": "అధునాతన సెట్టింగ్‌లు" + "advanced_settings": "అధునాతన సెట్టింగ్‌లు", + "upload_failed_all_providers": "అన్ని ప్రొవైడర్లు విఫలమయ్యాయి", + "upload_failed_preferred_guidance": "వేరే ప్రొవైడర్ ఎంచుకోండి లేదా యాదృచ్ఛికంగా సెట్ చేయండి.", + "media_hosting_random": "యాదృచ్ఛికం", + "media_hosting_preferred": "ఇష్టప్రదమైన ప్రొవైడర్", + "media_hosting_preferred_provider_label": "ప్రొవైడర్ ఎంచుకోండి", + "media_hosting_preferred_failure_guidance": "మీ ఇష్టప్రదమైన ప్రొవైడర్ విఫలమైతే, యాప్ ఇతర ప్రొవైడర్లను ప్రయత్నిస్తుంది (యాదృచ్ఛిక మోడ్) లేదా లోపం చూపిస్తుంది (ఇష్టప్రదమైన మోడ్).", + "media_hosting_all_providers_failed_guidance": "అన్ని అప్‌లోడ్ ప్రొవైడర్లు విఫలమయ్యాయి. మళ్ళీ ప్రయత్నించండి లేదా లింక్‌ను మాన్యువల్‌గా అతికించండి.", + "upload_not_supported_web_before_link": "సాంకేతిక పరిమితుల కారణంగా (CORS) మీ బ్రౌజర్ మీడియా అప్‌లోడ్‌ను మద్దతు ఇవ్వదు. ఇది 5chan Android యాప్ మరియు Mac/Linux/Windows డెస్క్‌టాప్ యాప్‌లో మద్దతు ఇస్తుంది.", + "upload_not_supported_web_link_text": "అధికారిక రిలీజ్ పేజీ నుండి డౌన్‌లోడ్ చేయండి" } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 873688d6..f6d5d614 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "ไม่มี", "media_hosting_none_tip": "ซ่อนปุ่มอัปโหลดไฟล์จากแบบฟอร์มโพสต์และตอบกลับ", "media_hosting": "การโฮสต์สื่อ", - "advanced_settings": "การตั้งค่าขั้นสูง" + "advanced_settings": "การตั้งค่าขั้นสูง", + "upload_failed_all_providers": "ผู้ให้บริการทั้งหมดล้มเหลว", + "upload_failed_preferred_guidance": "เลือกผู้ให้บริการอื่นหรือตั้งเป็นแบบสุ่ม", + "media_hosting_random": "สุ่ม", + "media_hosting_preferred": "ผู้ให้บริการที่ต้องการ", + "media_hosting_preferred_provider_label": "เลือกผู้ให้บริการ", + "media_hosting_preferred_failure_guidance": "หากผู้ให้บริการที่คุณต้องการล้มเหลว แอปจะลองผู้ให้บริการอื่น (โหมดสุ่ม) หรือแสดงข้อผิดพลาด (โหมดที่ต้องการ)", + "media_hosting_all_providers_failed_guidance": "ผู้ให้บริการอัปโหลดทั้งหมดล้มเหลว ลองอีกครั้งหรือวางลิงก์ด้วยตนเอง", + "upload_not_supported_web_before_link": "การอัปโหลดสื่อไม่รองรับโดยเบราว์เซอร์ของคุณเนื่องจากข้อจำกัดทางเทคนิค (CORS) รองรับในแอป 5chan สำหรับ Android และแอปเดสก์ท็อป Mac/Linux/Windows", + "upload_not_supported_web_link_text": "ดาวน์โหลดจากหน้าปล่อยรุ่นอย่างเป็นทางการ" } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index b0ce300c..3104f43f 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Yok", "media_hosting_none_tip": "Yazı ve yanıt formlarından dosya yükleme düğmesini gizler", "media_hosting": "Ortam Barındırma", - "advanced_settings": "Gelişmiş ayarlar" + "advanced_settings": "Gelişmiş ayarlar", + "upload_failed_all_providers": "Tüm sağlayıcılar başarısız oldu", + "upload_failed_preferred_guidance": "Farklı bir sağlayıcı seçin veya rastgeleye ayarlayın.", + "media_hosting_random": "Rastgele", + "media_hosting_preferred": "Tercih edilen sağlayıcı", + "media_hosting_preferred_provider_label": "Sağlayıcı seç", + "media_hosting_preferred_failure_guidance": "Tercih edilen sağlayıcı başarısız olursa, uygulama diğer sağlayıcıları dener (Rastgele mod) veya hata gösterir (Tercih modu).", + "media_hosting_all_providers_failed_guidance": "Tüm yükleme sağlayıcıları başarısız oldu. Tekrar deneyin veya bir bağlantıyı manuel olarak yapıştırın.", + "upload_not_supported_web_before_link": "Teknik kısıtlamalar (CORS) nedeniyle medya yükleme tarayıcınız tarafından desteklenmiyor. 5chan Android uygulaması ve Mac/Linux/Windows masaüstü uygulamasında desteklenmektedir.", + "upload_not_supported_web_link_text": "Resmi sürüm sayfasından indir" } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index e30ffb76..6e2d34c0 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Немає", "media_hosting_none_tip": "Приховує кнопку завантаження файлу з форм постів та відповідей", "media_hosting": "Хостинг медіа", - "advanced_settings": "Розширені налаштування" + "advanced_settings": "Розширені налаштування", + "upload_failed_all_providers": "Усі провайдери не справились", + "upload_failed_preferred_guidance": "Оберіть іншого провайдера або встановіть випадковий.", + "media_hosting_random": "Випадковий", + "media_hosting_preferred": "Уподобаний провайдер", + "media_hosting_preferred_provider_label": "Оберіть провайдера", + "media_hosting_preferred_failure_guidance": "Якщо ваш уподобаний провайдер не спрацює, застосунок спробує інших провайдерів (випадковий режим) або покаже помилку (режим уподобання).", + "media_hosting_all_providers_failed_guidance": "Усі провайдери завантаження не справились. Спробуйте знову або вставте посилання вручну.", + "upload_not_supported_web_before_link": "Завантаження медіа не підтримується вашим браузером через технічні обмеження (CORS). Підтримується в додатку 5chan для Android та десктопному додатку для Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Завантажити з офіційної сторінки випуску" } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 17f8a9a9..af7c6aa6 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "کوئی نہیں", "media_hosting_none_tip": "پوسٹ اور جواب کی فارمز سے فائل اپ لوڈ بٹن کو چھپاتا ہے", "media_hosting": "میڈیا ہوسٹنگ", - "advanced_settings": "جدید ترتیبات" + "advanced_settings": "جدید ترتیبات", + "upload_failed_all_providers": "تمام فراہم کنندہ ناکام ہو گئے", + "upload_failed_preferred_guidance": "مختلف فراہم کنندہ منتخب کریں یا بے ترتیب پر سیٹ کریں۔", + "media_hosting_random": "بے ترتیب", + "media_hosting_preferred": "پسندیدہ فراہم کنندہ", + "media_hosting_preferred_provider_label": "فراہم کنندہ منتخب کریں", + "media_hosting_preferred_failure_guidance": "اگر آپ کا پسندیدہ فراہم کنندہ ناکام ہو جاتا ہے، ایپ دیگر فراہم کنندوں کو آزماے گی (بے ترتیب موڈ) یا خرابی دکھائے گی (پسندیدہ موڈ)۔", + "media_hosting_all_providers_failed_guidance": "تمام اپ لوڈ فراہم کنندہ ناکام ہو گئے۔ دوبارہ کوشش کریں یا لنک دستی طور پر پیسٹ کریں۔", + "upload_not_supported_web_before_link": "تکنیکی پابندیوں (CORS) کی وجہ سے آپ کے براؤزر میڈیا اپ لوڈ کی حمایت نہیں کرتا۔ یہ 5chan Android ایپ اور Mac/Linux/Windows ڈیسک ٹاپ ایپ میں تعاون یافتہ ہے۔", + "upload_not_supported_web_link_text": "سرکاری ریلیز صفحے سے ڈاؤن لوڈ کریں" } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 302a2393..ba048402 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "Không có", "media_hosting_none_tip": "Ẩn nút tải tệp từ các biểu mẫu bài viết và trả lời", "media_hosting": "Lưu trữ Phương tiện", - "advanced_settings": "Cài đặt nâng cao" + "advanced_settings": "Cài đặt nâng cao", + "upload_failed_all_providers": "Tất cả nhà cung cấp đều thất bại", + "upload_failed_preferred_guidance": "Chọn nhà cung cấp khác hoặc đặt thành ngẫu nhiên.", + "media_hosting_random": "Ngẫu nhiên", + "media_hosting_preferred": "Nhà cung cấp ưu tiên", + "media_hosting_preferred_provider_label": "Chọn nhà cung cấp", + "media_hosting_preferred_failure_guidance": "Nếu nhà cung cấp ưu tiên của bạn thất bại, ứng dụng sẽ thử các nhà cung cấp khác (chế độ ngẫu nhiên) hoặc hiển thị lỗi (chế độ ưu tiên).", + "media_hosting_all_providers_failed_guidance": "Tất cả nhà cung cấp tải lên đều thất bại. Thử lại hoặc dán liên kết thủ công.", + "upload_not_supported_web_before_link": "Tải lên phương tiện không được trình duyệt của bạn hỗ trợ do hạn chế kỹ thuật (CORS). Được hỗ trợ trong ứng dụng 5chan Android và ứng dụng desktop Mac/Linux/Windows.", + "upload_not_supported_web_link_text": "Tải xuống từ trang phát hành chính thức" } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 2eeca26c..c8bf04c5 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -287,5 +287,14 @@ "media_hosting_none": "无", "media_hosting_none_tip": "隐藏帖子和回复表单中的文件上传按钮", "media_hosting": "媒体托管", - "advanced_settings": "高级设置" + "advanced_settings": "高级设置", + "upload_failed_all_providers": "所有提供商均失败", + "upload_failed_preferred_guidance": "选择其他提供商或设置为随机。", + "media_hosting_random": "随机", + "media_hosting_preferred": "首选提供商", + "media_hosting_preferred_provider_label": "选择提供商", + "media_hosting_preferred_failure_guidance": "如果首选提供商失败,应用将尝试其他提供商(随机模式)或显示错误(首选模式)。", + "media_hosting_all_providers_failed_guidance": "所有上传提供商均失败。请重试或手动粘贴链接。", + "upload_not_supported_web_before_link": "由于技术限制(CORS),您的浏览器不支持上传媒体。在 5chan Android 应用和 Mac/Linux/Windows 桌面应用中支持。", + "upload_not_supported_web_link_text": "从官方发布页面下载" } diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 66c64155..3f634405 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -16,6 +16,7 @@ import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; import usePublishPost from '../../hooks/use-publish-post'; import usePublishReply from '../../hooks/use-publish-reply'; import { useFileUpload } from '../../hooks/use-file-upload'; +import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; import useMediaHostingStore from '../../stores/use-media-hosting-store'; import styles from './post-form.module.css'; import capitalize from 'lodash/capitalize'; @@ -211,8 +212,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: } }, }); - const selectedProvider = useMediaHostingStore((state) => state.selectedProvider); - const showUploadControls = selectedProvider !== 'none'; + const uploadMode = useMediaHostingStore((state) => state.uploadMode); + const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); const hasInitializedDisplayName = useRef(false); useEffect(() => { diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 0653c7e9..c91e1309 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -9,6 +9,7 @@ import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils'; import useSelectedTextStore from '../../stores/use-selected-text-store'; import useReplyModalStore from '../../stores/use-reply-modal-store'; +import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; import useMediaHostingStore from '../../stores/use-media-hosting-store'; import { useDirectoryByAddress } from '../../hooks/use-directories'; import usePublishReply from '../../hooks/use-publish-reply'; @@ -275,8 +276,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa } }, }); - const selectedProvider = useMediaHostingStore((state) => state.selectedProvider); - const showUploadControls = selectedProvider !== 'none'; + const uploadMode = useMediaHostingStore((state) => state.uploadMode); + const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime()); const hasInitializedDisplayName = useRef(false); useEffect(() => { diff --git a/src/components/settings-modal/interface-settings/interface-settings.module.css b/src/components/settings-modal/interface-settings/interface-settings.module.css index d99c7e87..a4f4872c 100644 --- a/src/components/settings-modal/interface-settings/interface-settings.module.css +++ b/src/components/settings-modal/interface-settings/interface-settings.module.css @@ -50,3 +50,20 @@ margin: 2px 0 5px 0; padding-left: 19px; } + +.webUploadWarning { + font-size: 0.8em; + padding: 5px 0; + margin-left: 10px; +} + +.webUploadWarning a { + color: var(--post-link-text-color); + text-decoration: var(--post-content-link-text-decoration); + text-transform: lowercase; +} + +.webUploadWarning a:hover { + color: var(--post-link-text-color-hover); + text-decoration: var(--post-content-link-text-decoration-hover); +} diff --git a/src/components/settings-modal/media-hosting-settings/__tests__/media-hosting-settings.test.tsx b/src/components/settings-modal/media-hosting-settings/__tests__/media-hosting-settings.test.tsx index 612d10f8..7e74232e 100644 --- a/src/components/settings-modal/media-hosting-settings/__tests__/media-hosting-settings.test.tsx +++ b/src/components/settings-modal/media-hosting-settings/__tests__/media-hosting-settings.test.tsx @@ -12,16 +12,27 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -const mockSetSelectedProvider = vi.fn(); -const selectedProviderRef = vi.hoisted(() => ({ value: 'catbox' as string })); +const mockGetPlatform = vi.fn(() => 'web'); +vi.mock('@capacitor/core', () => ({ + Capacitor: { getPlatform: () => mockGetPlatform() }, +})); + +const mockSetUploadMode = vi.fn(); +const mockSetPreferredProvider = vi.fn(); +const uploadModeRef = vi.hoisted(() => ({ value: 'random' as 'random' | 'preferred' | 'none' })); +const preferredProviderRef = vi.hoisted(() => ({ + value: 'catbox' as 'catbox' | 'imgur' | 'postimages', +})); vi.mock('../../../../stores/use-media-hosting-store', async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - default: (selector: (state: { selectedProvider: string; setSelectedProvider: (provider: string) => void }) => unknown) => + default: (selector: (state: Record) => unknown) => selector({ - selectedProvider: selectedProviderRef.value, - setSelectedProvider: mockSetSelectedProvider, + uploadMode: uploadModeRef.value, + preferredProvider: preferredProviderRef.value, + setUploadMode: mockSetUploadMode, + setPreferredProvider: mockSetPreferredProvider, }), }; }); @@ -38,7 +49,10 @@ const render = () => { describe('MediaHostingSettings', () => { beforeEach(() => { vi.clearAllMocks(); - selectedProviderRef.value = 'catbox'; + uploadModeRef.value = 'random'; + preferredProviderRef.value = 'catbox'; + mockGetPlatform.mockReturnValue('web'); + (window as unknown as { electronApi?: unknown }).electronApi = undefined; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -49,44 +63,132 @@ describe('MediaHostingSettings', () => { container.remove(); }); - it('renders None option and all providers', () => { + it('renders mode options: Random, Preferred, None', () => { render(); - const radios = container.querySelectorAll('input[type="radio"]'); - expect(radios.length).toBe(MEDIA_HOSTING_PROVIDERS.length + 1); + const radios = container.querySelectorAll('input[name="media-hosting-provider"]'); + expect(radios.length).toBe(3); + expect(container.textContent).toContain('media_hosting_random'); + expect(container.textContent).toContain('media_hosting_preferred'); expect(container.textContent).toContain('media_hosting_none'); + }); + + it('shows provider list when Preferred is selected', () => { + uploadModeRef.value = 'preferred'; + render(); for (const provider of MEDIA_HOSTING_PROVIDERS) { - expect(container.textContent).toContain(provider.name); - expect(container.textContent).toContain(provider.url); + expect(container.textContent).toContain(provider.label); + expect(container.textContent).toContain(provider.homepageUrl); } }); - it('default selected is catbox', () => { + it('default selected is random', () => { render(); - const catboxRadio = container.querySelector('input[value="catbox"]'); - expect(catboxRadio).not.toBeNull(); - expect(catboxRadio?.checked).toBe(true); - const noneRadio = container.querySelector('input[value="none"]'); - expect(noneRadio?.checked).toBe(false); + const randomRadio = container.querySelector('input[value="random"]'); + expect(randomRadio).not.toBeNull(); + expect(randomRadio?.checked).toBe(true); }); - it('clicking a radio updates store selection', async () => { + it('clicking None updates store via setUploadMode', async () => { + mockGetPlatform.mockReturnValue('android'); render(); const noneRadio = container.querySelector('input[value="none"]'); expect(noneRadio).not.toBeNull(); await act(async () => { noneRadio?.click(); }); - expect(mockSetSelectedProvider).toHaveBeenCalledWith('none'); + expect(mockSetUploadMode).toHaveBeenCalledWith('none'); + }); - mockSetSelectedProvider.mockClear(); - selectedProviderRef.value = 'none'; + it('clicking provider in preferred mode calls setPreferredProvider', async () => { + uploadModeRef.value = 'preferred'; + preferredProviderRef.value = 'catbox'; + mockGetPlatform.mockReturnValue('android'); + render(); + const imgurRadio = container.querySelector('input[value="imgur"]'); + expect(imgurRadio).not.toBeNull(); + expect(imgurRadio?.disabled).toBe(false); + await act(async () => { + imgurRadio?.click(); + }); + expect(mockSetPreferredProvider).toHaveBeenCalledWith('imgur'); + }); + + it('clicking Random updates store via setUploadMode', async () => { + uploadModeRef.value = 'preferred'; + mockGetPlatform.mockReturnValue('android'); + render(); + const randomRadio = container.querySelector('input[value="random"]'); + expect(randomRadio).not.toBeNull(); + await act(async () => { + randomRadio?.click(); + }); + expect(mockSetUploadMode).toHaveBeenCalledWith('random'); + }); + + it('clicking Preferred updates store via setUploadMode', async () => { + mockGetPlatform.mockReturnValue('android'); + render(); + const preferredRadio = container.querySelector('input[value="preferred"]'); + expect(preferredRadio).not.toBeNull(); + await act(async () => { + preferredRadio?.click(); + }); + expect(mockSetUploadMode).toHaveBeenCalledWith('preferred'); + }); + + it('on web runtime, warning renders above mode radios and all radios are disabled', () => { + uploadModeRef.value = 'preferred'; + mockGetPlatform.mockReturnValue('web'); + (window as unknown as { electronApi?: unknown }).electronApi = undefined; render(); - const providerRadio = container.querySelector(`input[value="${MEDIA_HOSTING_PROVIDERS[0].id}"]`); - expect(providerRadio).not.toBeNull(); - await act(async () => { - providerRadio?.click(); - }); - expect(mockSetSelectedProvider).toHaveBeenCalledWith(MEDIA_HOSTING_PROVIDERS[0].id); + const warningDiv = Array.from(container.querySelectorAll('div')).find((d) => d.textContent?.includes('upload_not_supported_web_before_link')); + expect(warningDiv).toBeTruthy(); + const firstModeRadio = container.querySelector('input[name="media-hosting-provider"]'); + expect(firstModeRadio).toBeTruthy(); + expect(warningDiv!.compareDocumentPosition(firstModeRadio!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + const modeRadios = container.querySelectorAll('input[name="media-hosting-provider"]'); + expect(modeRadios.length).toBe(3); + for (const radio of modeRadios) { + expect(radio.disabled).toBe(true); + } + + const providerRadios = container.querySelectorAll('input[name="media-hosting-provider-provider"]'); + expect(providerRadios.length).toBe(MEDIA_HOSTING_PROVIDERS.length); + for (const radio of providerRadios) { + expect(radio.disabled).toBe(true); + } + + expect(container.textContent).toContain('upload_not_supported_web_before_link'); + expect(container.textContent).toContain('upload_not_supported_web_link_text'); + }); + + it('on web runtime, no Catbox-only web hint key is referenced', () => { + mockGetPlatform.mockReturnValue('web'); + (window as unknown as { electronApi?: unknown }).electronApi = undefined; + render(); + expect(container.textContent).not.toContain('media_hosting_provider_disabled_on_web'); + }); + + it('on electron runtime, all radios are enabled and no CORS warning is shown', () => { + uploadModeRef.value = 'preferred'; + mockGetPlatform.mockReturnValue('web'); + (window as unknown as { electronApi?: unknown }).electronApi = { isElectron: true }; + render(); + + const modeRadios = container.querySelectorAll('input[name="media-hosting-provider"]'); + expect(modeRadios.length).toBe(3); + for (const radio of modeRadios) { + expect(radio.disabled).toBe(false); + } + + const providerRadios = container.querySelectorAll('input[name="media-hosting-provider-provider"]'); + expect(providerRadios.length).toBe(MEDIA_HOSTING_PROVIDERS.length); + for (const radio of providerRadios) { + expect(radio.disabled).toBe(false); + } + + expect(container.textContent).not.toContain('upload_not_supported_web_before_link'); }); }); diff --git a/src/components/settings-modal/media-hosting-settings/media-hosting-settings.tsx b/src/components/settings-modal/media-hosting-settings/media-hosting-settings.tsx index 40a35b90..6bc1b3b0 100644 --- a/src/components/settings-modal/media-hosting-settings/media-hosting-settings.tsx +++ b/src/components/settings-modal/media-hosting-settings/media-hosting-settings.tsx @@ -1,32 +1,73 @@ +import { Capacitor } from '@capacitor/core'; import { useTranslation } from 'react-i18next'; import useMediaHostingStore, { MEDIA_HOSTING_PROVIDERS } from '../../../stores/use-media-hosting-store'; +import type { ProviderId } from '../../../lib/media-hosting/types'; import styles from '../interface-settings/interface-settings.module.css'; const RADIO_NAME = 'media-hosting-provider'; +const RELEASES_URL = 'https://github.com/bitsocialhq/5chan/releases/latest'; + +function isWebRuntime(): boolean { + return Capacitor.getPlatform() === 'web' && !window.electronApi?.isElectron; +} const MediaHostingSettings = () => { const { t } = useTranslation(); - const selectedProvider = useMediaHostingStore((state) => state.selectedProvider); - const setSelectedProvider = useMediaHostingStore((state) => state.setSelectedProvider); + const uploadMode = useMediaHostingStore((state) => state.uploadMode); + const preferredProvider = useMediaHostingStore((state) => state.preferredProvider); + const setUploadMode = useMediaHostingStore((state) => state.setUploadMode); + const setPreferredProvider = useMediaHostingStore((state) => state.setPreferredProvider); + const isWeb = isWebRuntime(); return (
- {MEDIA_HOSTING_PROVIDERS.map((provider) => ( -
- + {isWeb && ( +
+ {t('upload_not_supported_web_before_link')}{' '} + + {t('upload_not_supported_web_link_text')} +
- ))} + )}
+
+
+ + {uploadMode === 'preferred' && ( +
+ {MEDIA_HOSTING_PROVIDERS.map((provider) => ( +
+ +
+ ))} +
+ )} +
+
+
{t('media_hosting_none_tip')}
diff --git a/src/globals.d.ts b/src/globals.d.ts index d0d7a7f5..c8f80d3d 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -4,14 +4,15 @@ declare global { } } +import type { ProviderId } from './lib/media-hosting/types'; + declare global { interface Window { electronApi?: { isElectron: boolean; - invoke: (channel: string, ...args: any[]) => Promise; - getNotificationStatus: () => Promise<'granted' | 'denied' | 'not-determined' | 'not-supported'>; - getPlatform: () => Promise; - testNotification: () => Promise<{ success: boolean; reason?: string }>; + copyToClipboard: (text: string) => Promise<{ success: boolean; error?: string }>; + getPlatform: () => Promise<{ platform: NodeJS.Platform; arch: string; version: string }>; + automateUploadMedia: (options: { provider: ProviderId; filePath: string }) => Promise<{ url: string; provider: ProviderId }>; }; } } diff --git a/src/hooks/__tests__/use-file-upload.test.ts b/src/hooks/__tests__/use-file-upload.test.ts index 2e6f2b16..f85b8caa 100644 --- a/src/hooks/__tests__/use-file-upload.test.ts +++ b/src/hooks/__tests__/use-file-upload.test.ts @@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Capacitor } from '@capacitor/core'; import FileUploader from '../../plugins/file-uploader'; -import { uploadToCatbox } from '../../lib/utils/catbox-utils'; +import { orchestrateElectronUpload } from '../../lib/media-hosting/upload-orchestrator'; import { useFileUpload } from '../use-file-upload'; // Enable React's act environment for hook state updates in tests. @@ -27,9 +27,23 @@ vi.mock('../../lib/utils/catbox-utils', () => ({ uploadToCatbox: vi.fn(), })); -const selectedProviderRef = vi.hoisted(() => ({ value: 'catbox' as string })); +vi.mock('../../lib/media-hosting/upload-orchestrator', () => ({ + orchestrateElectronUpload: vi.fn(), +})); + +vi.mock('../../lib/media-hosting/provider-order', () => ({ + getProviderOrder: vi.fn((opts: { mode: string; preferredProvider: string; runtime: string }) => { + if (opts.mode === 'none') return []; + if (opts.runtime === 'web') return opts.preferredProvider === 'catbox' ? ['catbox'] : []; + return opts.mode === 'preferred' ? [opts.preferredProvider] : ['catbox', 'imgur', 'postimages']; + }), +})); + +const uploadModeRef = vi.hoisted(() => ({ value: 'random' as 'random' | 'preferred' | 'none' })); +const preferredProviderRef = vi.hoisted(() => ({ value: 'catbox' as 'catbox' | 'imgur' | 'postimages' })); vi.mock('../../stores/use-media-hosting-store', () => ({ - default: (selector: (s: { selectedProvider: string }) => unknown) => selector({ selectedProvider: selectedProviderRef.value }), + default: (selector: (s: { uploadMode: string; preferredProvider: string }) => unknown) => + selector({ uploadMode: uploadModeRef.value, preferredProvider: preferredProviderRef.value }), })); type HookSnapshot = ReturnType; @@ -73,7 +87,8 @@ const selectFileFromHiddenInput = async (file: File | null) => { describe('useFileUpload', () => { beforeEach(() => { vi.clearAllMocks(); - selectedProviderRef.value = 'catbox'; + uploadModeRef.value = 'random'; + preferredProviderRef.value = 'catbox'; latestHook = null; container = document.createElement('div'); document.body.append(container); @@ -93,10 +108,13 @@ describe('useFileUpload', () => { }); it('uploads via Android plugin and updates state', async () => { + uploadModeRef.value = 'preferred'; + preferredProviderRef.value = 'catbox'; vi.mocked(Capacitor.getPlatform).mockReturnValue('android'); vi.mocked(FileUploader.pickAndUploadMedia).mockResolvedValue({ url: 'https://files.catbox.moe/android.jpg', fileName: 'android.jpg', + provider: 'catbox', }); const { onUploadComplete, hook } = mountHook(); @@ -111,10 +129,12 @@ describe('useFileUpload', () => { expect(hook().isUploading).toBe(false); }); - it('uploads via Electron file picker + catbox utility', async () => { + it('uploads via Electron file picker + orchestrator', async () => { + uploadModeRef.value = 'preferred'; + preferredProviderRef.value = 'catbox'; vi.mocked(Capacitor.getPlatform).mockReturnValue('ios'); window.electronApi = { isElectron: true } as any; - vi.mocked(uploadToCatbox).mockResolvedValue('https://files.catbox.moe/electron.png'); + vi.mocked(orchestrateElectronUpload).mockResolvedValue('https://files.catbox.moe/electron.png'); const selectedFile = new File(['abc'], 'electron.png', { type: 'image/png' }); const { onUploadComplete, hook } = mountHook(); @@ -128,13 +148,15 @@ describe('useFileUpload', () => { await uploadPromise; }); - expect(uploadToCatbox).toHaveBeenCalledWith(selectedFile); + expect(orchestrateElectronUpload).toHaveBeenCalledWith(selectedFile, ['catbox']); expect(onUploadComplete).toHaveBeenCalledWith('https://files.catbox.moe/electron.png', 'electron.png'); expect(hook().uploadedFileName).toBe('electron.png'); expect(hook().isUploading).toBe(false); }); it('shows web fallback alert and does not upload', async () => { + uploadModeRef.value = 'preferred'; + preferredProviderRef.value = 'catbox'; vi.mocked(Capacitor.getPlatform).mockReturnValue('web'); window.electronApi = undefined; const { onUploadComplete, hook } = mountHook(); @@ -144,13 +166,15 @@ describe('useFileUpload', () => { }); expect(window.alert).toHaveBeenCalledWith('upload_not_supported_web'); - expect(uploadToCatbox).not.toHaveBeenCalled(); + expect(orchestrateElectronUpload).not.toHaveBeenCalled(); expect(FileUploader.pickAndUploadMedia).not.toHaveBeenCalled(); expect(onUploadComplete).not.toHaveBeenCalled(); expect(hook().isUploading).toBe(false); }); it('silently ignores file selection cancellation', async () => { + uploadModeRef.value = 'preferred'; + preferredProviderRef.value = 'catbox'; vi.mocked(Capacitor.getPlatform).mockReturnValue('ios'); window.electronApi = { isElectron: true } as any; const { onUploadComplete, hook } = mountHook(); @@ -164,13 +188,15 @@ describe('useFileUpload', () => { await uploadPromise; }); - expect(uploadToCatbox).not.toHaveBeenCalled(); + expect(orchestrateElectronUpload).not.toHaveBeenCalled(); expect(window.alert).not.toHaveBeenCalled(); expect(onUploadComplete).not.toHaveBeenCalled(); expect(hook().isUploading).toBe(false); }); - it('alerts on upload failure and resets uploading state', async () => { + it('alerts on preferred mode upload failure with actionable guidance', async () => { + uploadModeRef.value = 'preferred'; + preferredProviderRef.value = 'catbox'; vi.mocked(Capacitor.getPlatform).mockReturnValue('android'); vi.mocked(FileUploader.pickAndUploadMedia).mockRejectedValue(new Error('boom')); const { onUploadComplete, hook } = mountHook(); @@ -179,17 +205,18 @@ describe('useFileUpload', () => { await hook().handleUpload(); }); - expect(window.alert).toHaveBeenCalledWith('upload_failed: boom'); + expect(window.alert).toHaveBeenCalledWith('upload_failed: boom. upload_failed_preferred_guidance'); expect(onUploadComplete).not.toHaveBeenCalled(); expect(hook().isUploading).toBe(false); }); - it('returns early when selectedProvider is none (no upload, no alert)', async () => { - selectedProviderRef.value = 'none'; + it('returns early when uploadMode is none (no upload, no alert)', async () => { + uploadModeRef.value = 'none'; vi.mocked(Capacitor.getPlatform).mockReturnValue('android'); vi.mocked(FileUploader.pickAndUploadMedia).mockResolvedValue({ url: 'https://files.catbox.moe/skip.jpg', fileName: 'skip.jpg', + provider: 'catbox', }); const { onUploadComplete, hook } = mountHook(); @@ -203,18 +230,95 @@ describe('useFileUpload', () => { expect(hook().isUploading).toBe(false); }); - it('returns early when selectedProvider is unknown (no crash, no upload)', async () => { - selectedProviderRef.value = 'unknown-provider'; - vi.mocked(Capacitor.getPlatform).mockReturnValue('ios'); - window.electronApi = { isElectron: true } as any; + it('shows web unsupported alert when preferred provider not available on web', async () => { + uploadModeRef.value = 'preferred'; + preferredProviderRef.value = 'imgur'; + vi.mocked(Capacitor.getPlatform).mockReturnValue('web'); const { onUploadComplete, hook } = mountHook(); await act(async () => { await hook().handleUpload(); }); - expect(uploadToCatbox).not.toHaveBeenCalled(); - expect(window.alert).not.toHaveBeenCalled(); + expect(window.alert).toHaveBeenCalledWith('upload_not_supported_web'); + expect(orchestrateElectronUpload).not.toHaveBeenCalled(); + expect(onUploadComplete).not.toHaveBeenCalled(); + expect(hook().isUploading).toBe(false); + }); + + it('Android call includes provider order payload', async () => { + uploadModeRef.value = 'random'; + preferredProviderRef.value = 'catbox'; + vi.mocked(Capacitor.getPlatform).mockReturnValue('android'); + vi.mocked(FileUploader.pickAndUploadMedia).mockResolvedValue({ + url: 'https://files.catbox.moe/random.jpg', + fileName: 'random.jpg', + provider: 'catbox', + }); + + const { onUploadComplete, hook } = mountHook(); + + await act(async () => { + await hook().handleUpload(); + }); + + expect(FileUploader.pickAndUploadMedia).toHaveBeenCalledWith({ + providerOrder: expect.any(Array), + }); + const call = vi.mocked(FileUploader.pickAndUploadMedia).mock.calls[0][0]; + expect(call?.providerOrder).toEqual(expect.arrayContaining(['catbox'])); + expect(onUploadComplete).toHaveBeenCalledWith('https://files.catbox.moe/random.jpg', 'random.jpg'); + }); + + it('random mode: Electron upload succeeds when orchestrator resolves', async () => { + uploadModeRef.value = 'random'; + preferredProviderRef.value = 'catbox'; + vi.mocked(Capacitor.getPlatform).mockReturnValue('ios'); + window.electronApi = { isElectron: true } as any; + vi.mocked(orchestrateElectronUpload).mockResolvedValue('https://imgur.com/abc'); + + const selectedFile = new File(['xyz'], 'pic.png', { type: 'image/png' }); + const { onUploadComplete, hook } = mountHook(); + + let uploadPromise: Promise | undefined; + await act(async () => { + uploadPromise = hook().handleUpload(); + }); + await selectFileFromHiddenInput(selectedFile); + await act(async () => { + await uploadPromise; + }); + + expect(orchestrateElectronUpload).toHaveBeenCalledWith(selectedFile, expect.any(Array)); + expect(onUploadComplete).toHaveBeenCalledWith('https://imgur.com/abc', 'pic.png'); + expect(hook().isUploading).toBe(false); + }); + + it('random mode: all providers fail shows aggregated alert', async () => { + uploadModeRef.value = 'random'; + preferredProviderRef.value = 'catbox'; + vi.mocked(Capacitor.getPlatform).mockReturnValue('ios'); + window.electronApi = { isElectron: true } as any; + const err = new Error('All providers failed') as Error & { attempts: { provider: string; error: string }[] }; + err.attempts = [ + { provider: 'catbox', error: 'timeout' }, + { provider: 'imgur', error: 'rate limit' }, + ]; + vi.mocked(orchestrateElectronUpload).mockRejectedValue(err); + + const selectedFile = new File(['x'], 'fail.png', { type: 'image/png' }); + const { onUploadComplete, hook } = mountHook(); + + let uploadPromise: Promise | undefined; + await act(async () => { + uploadPromise = hook().handleUpload(); + }); + await selectFileFromHiddenInput(selectedFile); + await act(async () => { + await uploadPromise; + }); + + expect(window.alert).toHaveBeenCalledWith('upload_failed. upload_failed_all_providers: catbox: timeout; imgur: rate limit'); expect(onUploadComplete).not.toHaveBeenCalled(); expect(hook().isUploading).toBe(false); }); diff --git a/src/hooks/use-file-upload.ts b/src/hooks/use-file-upload.ts index 2ef1449e..e8087213 100644 --- a/src/hooks/use-file-upload.ts +++ b/src/hooks/use-file-upload.ts @@ -2,14 +2,17 @@ import { useCallback, useState } from 'react'; import { Capacitor } from '@capacitor/core'; import { useTranslation } from 'react-i18next'; import FileUploader from '../plugins/file-uploader'; -import { uploadToCatbox } from '../lib/utils/catbox-utils'; +import { formatAggregatedError, formatPreferredModeError, type ProviderAttempt } from '../lib/media-hosting/error-format'; +import { getProviderOrder } from '../lib/media-hosting/provider-order'; +import { orchestrateElectronUpload } from '../lib/media-hosting/upload-orchestrator'; import useMediaHostingStore from '../stores/use-media-hosting-store'; const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled'; -async function uploadByProvider(provider: string, file: File): Promise { - if (provider === 'catbox') return uploadToCatbox(file); - throw new Error(`Unsupported media provider: ${provider}`); +function getRuntime(): 'web' | 'electron' | 'android' { + if (Capacitor.getPlatform() === 'android') return 'android'; + if (window.electronApi?.isElectron) return 'electron'; + return 'web'; } export interface UseFileUploadOptions { @@ -60,24 +63,25 @@ function selectFileViaInput(): Promise { export function useFileUpload(options: UseFileUploadOptions) { const { t } = useTranslation(); const { onUploadComplete } = options; - const selectedProvider = useMediaHostingStore((s) => s.selectedProvider); + const uploadMode = useMediaHostingStore((s) => s.uploadMode); + const preferredProvider = useMediaHostingStore((s) => s.preferredProvider); const [isUploading, setIsUploading] = useState(false); const [uploadedFileName, setUploadedFileName] = useState(null); const handleUpload = useCallback(async () => { - if (selectedProvider === 'none') return; - if (selectedProvider !== 'catbox') return; + if (uploadMode === 'none') return; + + const runtime = getRuntime(); + const order = getProviderOrder({ mode: uploadMode, preferredProvider, runtime }); try { setIsUploading(true); setUploadedFileName(null); if (Capacitor.getPlatform() === 'android') { - const result = await FileUploader.pickAndUploadMedia(); + const result = await FileUploader.pickAndUploadMedia({ providerOrder: order }); if (result.url) { - if (result.fileName) { - setUploadedFileName(result.fileName); - } + if (result.fileName) setUploadedFileName(result.fileName); onUploadComplete(result.url, result.fileName); } return; @@ -89,7 +93,7 @@ export function useFileUpload(options: UseFileUploadOptions) { throw new Error(FILE_SELECTION_CANCELLED_ERROR); } - const url = await uploadByProvider(selectedProvider, file); + const url = await orchestrateElectronUpload(file, order); setUploadedFileName(file.name); onUploadComplete(url, file.name); return; @@ -98,13 +102,20 @@ export function useFileUpload(options: UseFileUploadOptions) { window.alert(t('upload_not_supported_web')); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage !== FILE_SELECTION_CANCELLED_ERROR) { + if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return; + + const err = error as Error & { attempts?: ProviderAttempt[] }; + if (err.attempts && err.attempts.length > 0) { + window.alert(formatAggregatedError(err.attempts, t)); + } else if (uploadMode === 'preferred') { + window.alert(formatPreferredModeError(errorMessage, t)); + } else { window.alert(`${t('upload_failed')}: ${errorMessage}`); } } finally { setIsUploading(false); } - }, [onUploadComplete, t, selectedProvider]); + }, [onUploadComplete, t, uploadMode, preferredProvider]); return { isUploading, diff --git a/src/lib/media-hosting/__tests__/direct-url.test.ts b/src/lib/media-hosting/__tests__/direct-url.test.ts new file mode 100644 index 00000000..f1424ea9 --- /dev/null +++ b/src/lib/media-hosting/__tests__/direct-url.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { isDirectMediaUrl } from '../direct-url'; + +describe('direct-url', () => { + describe('isDirectMediaUrl', () => { + it('returns true for image extensions', () => { + expect(isDirectMediaUrl('https://example.com/photo.jpg')).toBe(true); + expect(isDirectMediaUrl('https://example.com/photo.jpeg')).toBe(true); + expect(isDirectMediaUrl('https://example.com/photo.png')).toBe(true); + expect(isDirectMediaUrl('https://example.com/photo.gif')).toBe(true); + expect(isDirectMediaUrl('https://example.com/photo.webp')).toBe(true); + }); + + it('returns true for video extensions', () => { + expect(isDirectMediaUrl('https://example.com/video.webm')).toBe(true); + expect(isDirectMediaUrl('https://example.com/video.mp4')).toBe(true); + expect(isDirectMediaUrl('https://example.com/video.mov')).toBe(true); + expect(isDirectMediaUrl('https://example.com/video.avi')).toBe(true); + expect(isDirectMediaUrl('https://example.com/video.mkv')).toBe(true); + expect(isDirectMediaUrl('https://example.com/video.gifv')).toBe(true); + }); + + it('strips query strings before checking', () => { + expect(isDirectMediaUrl('https://example.com/photo.jpg?size=large')).toBe(true); + expect(isDirectMediaUrl('https://example.com/page.html?img=photo.jpg')).toBe(false); + }); + + it('is case insensitive', () => { + expect(isDirectMediaUrl('https://example.com/photo.JPG')).toBe(true); + expect(isDirectMediaUrl('https://example.com/photo.PNG')).toBe(true); + }); + + it('returns false for non-media URLs', () => { + expect(isDirectMediaUrl('https://example.com/page.html')).toBe(false); + expect(isDirectMediaUrl('https://imgur.com/abc123')).toBe(false); + expect(isDirectMediaUrl('https://example.com/')).toBe(false); + }); + }); +}); diff --git a/src/lib/media-hosting/__tests__/provider-order.test.ts b/src/lib/media-hosting/__tests__/provider-order.test.ts new file mode 100644 index 00000000..fac2132e --- /dev/null +++ b/src/lib/media-hosting/__tests__/provider-order.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getPreferredOrder, getRandomOrder, getProviderOrder } from '../provider-order'; + +describe('provider-order', () => { + describe('getPreferredOrder', () => { + it('returns single-element array with preferred provider', () => { + expect(getPreferredOrder('catbox')).toEqual(['catbox']); + expect(getPreferredOrder('imgur')).toEqual(['imgur']); + expect(getPreferredOrder('postimages')).toEqual(['postimages']); + }); + }); + + describe('getRandomOrder', () => { + it('returns shuffled copy (Fisher-Yates) with default rng', () => { + const providers = ['catbox', 'imgur', 'postimages'] as const; + const result = getRandomOrder(providers); + expect(result).toHaveLength(3); + expect([...result].sort()).toEqual(['catbox', 'imgur', 'postimages']); + expect(result).not.toBe(providers); + }); + + it('uses provided rng for deterministic shuffle', () => { + const providers = ['catbox', 'imgur', 'postimages'] as const; + const rng = vi.fn().mockReturnValue(0.5); + const result = getRandomOrder(providers, rng); + expect(rng).toHaveBeenCalled(); + expect(result).toHaveLength(3); + }); + + it('handles empty array', () => { + expect(getRandomOrder([])).toEqual([]); + }); + + it('handles single element', () => { + expect(getRandomOrder(['catbox'])).toEqual(['catbox']); + }); + }); + + describe('getProviderOrder', () => { + it('returns empty array when mode is none', () => { + expect( + getProviderOrder({ + mode: 'none', + preferredProvider: 'catbox', + runtime: 'electron', + }), + ).toEqual([]); + }); + + it('returns preferred provider when mode is preferred and supported', () => { + expect( + getProviderOrder({ + mode: 'preferred', + preferredProvider: 'catbox', + runtime: 'web', + }), + ).toEqual(['catbox']); + expect( + getProviderOrder({ + mode: 'preferred', + preferredProvider: 'imgur', + runtime: 'electron', + }), + ).toEqual(['imgur']); + }); + + it('returns empty when preferred provider not supported on runtime', () => { + expect( + getProviderOrder({ + mode: 'preferred', + preferredProvider: 'imgur', + runtime: 'web', + }), + ).toEqual([]); + }); + + it('returns shuffled list when mode is random', () => { + const order = getProviderOrder({ + mode: 'random', + preferredProvider: 'catbox', + runtime: 'electron', + }); + expect(order).toHaveLength(3); + expect([...order].sort()).toEqual(['catbox', 'imgur', 'postimages']); + }); + + it('filters by runtime for random mode', () => { + const order = getProviderOrder({ + mode: 'random', + preferredProvider: 'catbox', + runtime: 'web', + }); + expect(order).toHaveLength(1); + expect(order).toEqual(['catbox']); + }); + }); +}); diff --git a/src/lib/media-hosting/__tests__/show-upload-controls.test.ts b/src/lib/media-hosting/__tests__/show-upload-controls.test.ts new file mode 100644 index 00000000..7f47a4af --- /dev/null +++ b/src/lib/media-hosting/__tests__/show-upload-controls.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { getShowUploadControls } from '../show-upload-controls'; + +describe('getShowUploadControls', () => { + it('returns true on web regardless of uploadMode', () => { + expect(getShowUploadControls('none', true)).toBe(true); + expect(getShowUploadControls('random', true)).toBe(true); + expect(getShowUploadControls('preferred', true)).toBe(true); + }); + + it('returns false on non-web when uploadMode is none', () => { + expect(getShowUploadControls('none', false)).toBe(false); + }); + + it('returns true on non-web when uploadMode is random or preferred', () => { + expect(getShowUploadControls('random', false)).toBe(true); + expect(getShowUploadControls('preferred', false)).toBe(true); + }); +}); diff --git a/src/lib/media-hosting/direct-url.ts b/src/lib/media-hosting/direct-url.ts new file mode 100644 index 00000000..ddcda933 --- /dev/null +++ b/src/lib/media-hosting/direct-url.ts @@ -0,0 +1,12 @@ +/** File extensions that denote direct media URLs (images + videos) */ +const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv'] as const; + +/** Returns true if the URL appears to point to a direct media file (image or video) */ +export function isDirectMediaUrl(url: string): boolean { + try { + const normalized = url.split('?')[0].toLowerCase(); + return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext)); + } catch { + return false; + } +} diff --git a/src/lib/media-hosting/error-format.ts b/src/lib/media-hosting/error-format.ts new file mode 100644 index 00000000..545f16ae --- /dev/null +++ b/src/lib/media-hosting/error-format.ts @@ -0,0 +1,24 @@ +import type { ProviderId } from './types'; + +export interface ProviderAttempt { + provider: ProviderId; + success: boolean; + error?: string; +} + +export type TranslateFn = (key: string) => string; + +/** + * Formats aggregated error message when all providers fail (random mode). + */ +export function formatAggregatedError(attempts: ProviderAttempt[], t: TranslateFn): string { + const details = attempts.map((a) => `${a.provider}: ${a.error ?? 'unknown'}`).join('; '); + return `${t('upload_failed')}. ${t('upload_failed_all_providers')}: ${details}`; +} + +/** + * Formats error for preferred mode with actionable guidance. + */ +export function formatPreferredModeError(errorMessage: string, t: TranslateFn): string { + return `${t('upload_failed')}: ${errorMessage}. ${t('upload_failed_preferred_guidance')}`; +} diff --git a/src/lib/media-hosting/provider-order.ts b/src/lib/media-hosting/provider-order.ts new file mode 100644 index 00000000..640b6d5a --- /dev/null +++ b/src/lib/media-hosting/provider-order.ts @@ -0,0 +1,37 @@ +import type { MediaHostingRuntime, ProviderId, UploadMode } from './types'; +import { MEDIA_HOSTING_PROVIDERS } from './providers'; + +/** Returns [preferredProvider] for preferred mode */ +export function getPreferredOrder(preferredProvider: ProviderId): ProviderId[] { + return [preferredProvider]; +} + +/** Fisher-Yates shuffle. Returns a new shuffled copy. */ +export function getRandomOrder(providers: readonly ProviderId[], rng: () => number = Math.random): ProviderId[] { + const result = [...providers]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +/** Filter providers to those supported on the given runtime */ +function getSupportedProviders(runtime: MediaHostingRuntime): ProviderId[] { + return MEDIA_HOSTING_PROVIDERS.filter((p) => p.supportedRuntimes.includes(runtime)).map((p) => p.id); +} + +/** Get ordered provider list for an upload attempt */ +export function getProviderOrder(options: { mode: UploadMode; preferredProvider: ProviderId; runtime: MediaHostingRuntime }): ProviderId[] { + const { mode, preferredProvider, runtime } = options; + const supported = getSupportedProviders(runtime); + + if (mode === 'none') return []; + if (mode === 'preferred') { + return supported.includes(preferredProvider) ? [preferredProvider] : []; + } + if (mode === 'random') { + return getRandomOrder(supported); + } + return []; +} diff --git a/src/lib/media-hosting/providers.ts b/src/lib/media-hosting/providers.ts new file mode 100644 index 00000000..ce7b6555 --- /dev/null +++ b/src/lib/media-hosting/providers.ts @@ -0,0 +1,34 @@ +import type { MediaHostingRuntime, ProviderId } from './types'; + +export interface ProviderDefinition { + id: ProviderId; + label: string; + homepageUrl: string; + /** Runtimes where automated upload is supported (non-web = no interactive fallback) */ + supportedRuntimes: readonly MediaHostingRuntime[]; +} + +/** All media hosting providers with metadata */ +export const MEDIA_HOSTING_PROVIDERS: readonly ProviderDefinition[] = [ + { + id: 'catbox', + label: 'Catbox', + homepageUrl: 'https://catbox.moe', + supportedRuntimes: ['web', 'electron', 'android'], + }, + { + id: 'imgur', + label: 'Imgur', + homepageUrl: 'https://imgur.com', + supportedRuntimes: ['electron', 'android'], + }, + { + id: 'postimages', + label: 'Postimages', + homepageUrl: 'https://postimages.org', + supportedRuntimes: ['electron', 'android'], + }, +] as const; + +/** Provider IDs for ordering */ +export const PROVIDER_IDS: readonly ProviderId[] = MEDIA_HOSTING_PROVIDERS.map((p) => p.id); diff --git a/src/lib/media-hosting/show-upload-controls.ts b/src/lib/media-hosting/show-upload-controls.ts new file mode 100644 index 00000000..c8219871 --- /dev/null +++ b/src/lib/media-hosting/show-upload-controls.ts @@ -0,0 +1,16 @@ +import { Capacitor } from '@capacitor/core'; +import type { UploadMode } from './types'; + +/** Web runtime = web browser, not Electron */ +export function isWebRuntime(): boolean { + return Capacitor.getPlatform() === 'web' && !window.electronApi?.isElectron; +} + +/** + * Whether to show the upload CTA in post/reply forms. + * On web runtime, always true (for app promotion); otherwise true when uploadMode !== 'none'. + */ +export function getShowUploadControls(uploadMode: UploadMode, isWeb: boolean): boolean { + if (isWeb) return true; + return uploadMode !== 'none'; +} diff --git a/src/lib/media-hosting/types.ts b/src/lib/media-hosting/types.ts new file mode 100644 index 00000000..fb068ed8 --- /dev/null +++ b/src/lib/media-hosting/types.ts @@ -0,0 +1,30 @@ +/** Supported media hosting provider identifiers */ +export type ProviderId = 'catbox' | 'imgur' | 'postimages'; + +/** User-facing upload mode */ +export type UploadMode = 'random' | 'preferred' | 'none'; + +/** Runtime environment for automation support */ +export type MediaHostingRuntime = 'web' | 'electron' | 'android'; + +/** Result of a single provider upload attempt */ +export interface ProviderAttempt { + provider: ProviderId; + success: boolean; + url?: string; + error?: string; +} + +/** Successful upload result */ +export interface ProviderSuccess { + provider: ProviderId; + url: string; + fileName: string; + attempts?: ProviderAttempt[]; +} + +/** Aggregate error when all providers fail */ +export interface ProviderAggregateError { + attempts: ProviderAttempt[]; + message: string; +} diff --git a/src/lib/media-hosting/upload-orchestrator.ts b/src/lib/media-hosting/upload-orchestrator.ts new file mode 100644 index 00000000..cae05db0 --- /dev/null +++ b/src/lib/media-hosting/upload-orchestrator.ts @@ -0,0 +1,50 @@ +import type { ProviderId } from './types'; +import { uploadToCatbox } from '../utils/catbox-utils'; + +export interface OrchestratorAttempt { + provider: ProviderId; + success: boolean; + url?: string; + error?: string; +} + +/** + * Uploads a file via a single provider. Catbox uses the web API; + * imgur/postimages use Electron automation when available. + */ +async function uploadViaProvider(provider: ProviderId, file: File): Promise { + if (provider === 'catbox') return uploadToCatbox(file); + if (provider === 'imgur' || provider === 'postimages') { + const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia; + if (fn) { + const filePath = (file as File & { path?: string }).path; + if (!filePath) throw new Error('File path required for Electron automation'); + const { url } = await fn({ provider, filePath }); + return url; + } + throw new Error(`Provider ${provider} requires Electron (automateUploadMedia not available)`); + } + throw new Error(`Unsupported provider: ${provider}`); +} + +/** + * Orchestrates Electron upload: tries each provider in order, returns URL on first success. + * Collects attempt errors. Throws with attempts if all fail. + */ +export async function orchestrateElectronUpload(file: File, providerOrder: ProviderId[]): Promise { + const attempts: OrchestratorAttempt[] = []; + + for (const provider of providerOrder) { + try { + const url = await uploadViaProvider(provider, file); + return url; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + attempts.push({ provider, success: false, error: msg }); + } + } + + const err = new Error('All providers failed') as Error & { attempts: OrchestratorAttempt[] }; + err.attempts = attempts; + throw err; +} diff --git a/src/plugins/file-uploader.ts b/src/plugins/file-uploader.ts index f1627b3c..33807664 100644 --- a/src/plugins/file-uploader.ts +++ b/src/plugins/file-uploader.ts @@ -1,7 +1,19 @@ import { registerPlugin } from '@capacitor/core'; +import type { ProviderAttempt, ProviderId } from '../lib/media-hosting/types'; + +export interface PickAndUploadMediaOptions { + providerOrder: ProviderId[]; +} + +export interface PickAndUploadMediaResult { + url: string; + fileName: string; + provider: ProviderId; + attempts?: ProviderAttempt[]; +} export interface FileUploaderPlugin { - pickAndUploadMedia(): Promise<{ url: string; fileName: string }>; + pickAndUploadMedia(options?: PickAndUploadMediaOptions): Promise; } const FileUploader = registerPlugin('FileUploader'); diff --git a/src/stores/__tests__/use-media-hosting-store.test.ts b/src/stores/__tests__/use-media-hosting-store.test.ts index 0d1eac3b..48ca05f1 100644 --- a/src/stores/__tests__/use-media-hosting-store.test.ts +++ b/src/stores/__tests__/use-media-hosting-store.test.ts @@ -13,30 +13,54 @@ describe('useMediaHostingStore', () => { afterEach(() => { setItemSpy.mockRestore(); - useMediaHostingStore.getState().setSelectedProvider('catbox'); + useMediaHostingStore.getState().setUploadMode('random'); + useMediaHostingStore.getState().setPreferredProvider('catbox'); }); - it('exports MEDIA_HOSTING_PROVIDERS with at least Catbox provider', () => { + it('exports MEDIA_HOSTING_PROVIDERS with ids, labels, homepage URLs, runtime metadata', () => { expect(MEDIA_HOSTING_PROVIDERS).toBeDefined(); - expect(MEDIA_HOSTING_PROVIDERS.length).toBeGreaterThanOrEqual(1); + expect(MEDIA_HOSTING_PROVIDERS.length).toBe(3); const catbox = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'catbox'); - expect(catbox).toEqual({ id: 'catbox', name: 'Catbox', url: 'https://catbox.moe' }); + expect(catbox).toEqual({ + id: 'catbox', + label: 'Catbox', + homepageUrl: 'https://catbox.moe', + supportedRuntimes: ['web', 'electron', 'android'], + }); }); - it('defaults selectedProvider to catbox', () => { - const { selectedProvider } = useMediaHostingStore.getState(); - expect(selectedProvider).toBe('catbox'); + it('defaults uploadMode to random and preferredProvider to catbox', () => { + const { uploadMode, preferredProvider } = useMediaHostingStore.getState(); + expect(uploadMode).toBe('random'); + expect(preferredProvider).toBe('catbox'); }); - it('sets selectedProvider to none when setSelectedProvider is called with none', () => { - useMediaHostingStore.getState().setSelectedProvider('none'); - const { selectedProvider } = useMediaHostingStore.getState(); - expect(selectedProvider).toBe('none'); + it('sets uploadMode to none when setUploadMode is called with none', () => { + useMediaHostingStore.getState().setUploadMode('none'); + const { uploadMode } = useMediaHostingStore.getState(); + expect(uploadMode).toBe('none'); }); - it('persists state to localStorage when setSelectedProvider is called', () => { - useMediaHostingStore.getState().setSelectedProvider('none'); + it('persists state to localStorage when setUploadMode is called', () => { + useMediaHostingStore.getState().setUploadMode('none'); - expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"selectedProvider":"none"')); + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"uploadMode":"none"')); + }); + + it('persists preferredProvider when setPreferredProvider is called', () => { + useMediaHostingStore.getState().setPreferredProvider('imgur'); + + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"preferredProvider":"imgur"')); + }); + + it('migrates legacy selectedProvider state to random + catbox', async () => { + const legacyState = { state: { selectedProvider: 'imgur' }, version: 0 }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(legacyState)); + + await useMediaHostingStore.persist.rehydrate(); + + const { uploadMode, preferredProvider } = useMediaHostingStore.getState(); + expect(uploadMode).toBe('random'); + expect(preferredProvider).toBe('catbox'); }); }); diff --git a/src/stores/use-media-hosting-store.ts b/src/stores/use-media-hosting-store.ts index fa03249d..1efa65fb 100644 --- a/src/stores/use-media-hosting-store.ts +++ b/src/stores/use-media-hosting-store.ts @@ -1,23 +1,44 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import type { ProviderId, UploadMode } from '../lib/media-hosting/types'; +import { MEDIA_HOSTING_PROVIDERS as PROVIDER_REGISTRY } from '../lib/media-hosting/providers'; -export const MEDIA_HOSTING_PROVIDERS = [{ id: 'catbox', name: 'Catbox', url: 'https://catbox.moe' }] as const; - -export type MediaHostingSelection = (typeof MEDIA_HOSTING_PROVIDERS)[number]['id'] | 'none' | string; +/** Provider registry for UI: id, label, homepage URL, runtime support */ +export const MEDIA_HOSTING_PROVIDERS = PROVIDER_REGISTRY; interface MediaHostingStore { - selectedProvider: MediaHostingSelection; - setSelectedProvider: (provider: string) => void; + uploadMode: UploadMode; + preferredProvider: ProviderId; + setUploadMode: (mode: UploadMode) => void; + setPreferredProvider: (provider: ProviderId) => void; +} + +const PERSIST_VERSION = 2; + +function migrate(_state: unknown): MediaHostingStore { + return { + uploadMode: 'random', + preferredProvider: 'catbox', + } as MediaHostingStore; } const useMediaHostingStore = create()( persist( (set) => ({ - selectedProvider: 'catbox', - setSelectedProvider: (provider) => set({ selectedProvider: provider }), + uploadMode: 'random', + preferredProvider: 'catbox', + setUploadMode: (mode) => set({ uploadMode: mode }), + setPreferredProvider: (provider) => set({ preferredProvider: provider }), }), { name: 'media-hosting-storage', + version: PERSIST_VERSION, + migrate: (persistedState, version) => { + if (version < PERSIST_VERSION) { + return migrate(persistedState); + } + return persistedState as MediaHostingStore; + }, }, ), );