FileUpload: UI stuff (#1043)

* FileUpload: use XMLHttpRequest instead of fetch

* FileUpload: improve the file host picker settings UI

* FileUpload: track the upload progress instead of fallback count

* fix lint

* fix moreStickers

* Update stickers.ts

---------

Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
Key 2026-05-03 21:35:00 +01:00 • committed by GitHub
parent 16c9227074
commit a0c059e4ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 420 additions and 162 deletions

View file

@ -104,6 +104,21 @@ function handlePaste(event: ClipboardEvent) {
void uploadProvidedFiles(files);
}
function formatBytes(bytes: number): string {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
const ProgressBarInner = () => {
const [state, setState] = useState(getUploadState);
@ -112,6 +127,9 @@ const ProgressBarInner = () => {
if (state.phase === "idle") return null;
const percentage = Math.max(0, Math.min(100, state.percent));
const progressLabel = state.totalBytes > 0
? `${Math.round(percentage)}% - ${formatBytes(state.transferredBytes)} of ${formatBytes(state.totalBytes)}`
: `${Math.round(percentage)}%`;
return (
<div
@ -123,6 +141,9 @@ const ProgressBarInner = () => {
{state.status || "Uploading..."}
</div>
<div className={cl("progress-meta")}>
<span className={cl("progress-percent")}>
{progressLabel}
</span>
<span className={cl("progress-attempt")}>
{state.attempt > 0 && state.totalAttempts > 0 ? `${state.attempt}/${state.totalAttempts}` : ""}
</span>

View file

@ -285,7 +285,7 @@ function SettingTextInput(props: {
const { description, name, onChange, placeholder, value } = props;
return (
<SettingsSection name={name} description={description}>
<SettingsSection name={name} description={description ?? ""}>
<TextInput
value={value}
onChange={onChange}
@ -295,6 +295,60 @@ function SettingTextInput(props: {
);
}
function SettingGroup(props: {
children: React.ReactNode;
description?: string;
name: string;
}) {
const { children, description, name } = props;
return (
<SettingsSection name={name} description={description ?? ""}>
<div className={cl("group")}>
{children}
</div>
</SettingsSection>
);
}
function SettingSwitch(props: {
checked: boolean;
description: string;
name: string;
onChange: (value: boolean) => void;
}) {
const { checked, description, name, onChange } = props;
return (
<SettingsSection tag="label" name={name} description={description} inlineSetting>
<Switch checked={checked} onChange={onChange} />
</SettingsSection>
);
}
function ServicePicker(props: {
onChange: (service: ServiceType) => void;
value: ServiceType;
}) {
const { onChange, value } = props;
return (
<div className={cl("service-grid")}>
{serviceOptions.map(option => (
<button
key={option.value}
type="button"
className={cl("service-option")}
data-selected={option.value === value}
onClick={() => onChange(option.value)}
>
<span className={cl("service-option-label")}>{option.label}</span>
</button>
))}
</div>
);
}
function FallbackOrderSettings() {
const update = useForceUpdater();
const { store } = settings;
@ -413,21 +467,18 @@ export function SettingsComponent() {
return (
<>
<SettingsSection name="Service Type" description="The upload service to use">
<Select
options={serviceOptions}
isSelected={v => v === store.serviceType}
select={v => {
store.serviceType = v;
<SettingsSection name="Upload Service" description="Choose where FileUpload sends new files.">
<ServicePicker
value={store.serviceType as ServiceType}
onChange={service => {
store.serviceType = service;
update();
}}
serialize={v => v}
placeholder="Select a service"
/>
</SettingsSection>
{isZipline && (
<>
<SettingGroup name="Zipline" description="Connection details for your Zipline instance.">
<SettingTextInput
name="Service URL"
description="The URL of your Zipline instance"
@ -449,31 +500,35 @@ export function SettingsComponent() {
onChange={v => store.folderId = v}
placeholder="Leave empty for no folder"
/>
</>
</SettingGroup>
)}
{isEzHost && (
<SettingTextInput
name="E-Z Host API Key"
description="Your E-Z Host API key"
value={store.ezHostKey}
onChange={v => store.ezHostKey = v}
placeholder="Your E-Z Host API key"
/>
<SettingGroup name="E-Z Host" description="Connection details for E-Z Host uploads.">
<SettingTextInput
name="E-Z Host API Key"
description="Your E-Z Host API key"
value={store.ezHostKey}
onChange={v => store.ezHostKey = v}
placeholder="Your E-Z Host API key"
/>
</SettingGroup>
)}
{isNest && (
<SettingTextInput
name="Nest Token"
description="Your Nest API authorization token"
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
<SettingGroup name="Nest" description="Connection details for Nest uploads.">
<SettingTextInput
name="Nest Token"
description="Your Nest API authorization token"
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
</SettingGroup>
)}
{isS3 && (
<>
<SettingGroup name="S3-Compatible Storage" description="Connection details and object naming for your bucket.">
<SettingTextInput
name="S3 Endpoint URL"
description="S3-compatible endpoint (e.g. https://<accountid>.r2.cloudflarestorage.com)"
@ -530,23 +585,25 @@ export function SettingsComponent() {
onChange={v => store.s3Prefix = v}
placeholder="uploads/discord"
/>
<SettingsSection tag="label" name="Use Path-Style Endpoint" description="Use endpoint/bucket/key format (recommended for R2)" inlineSetting>
<Switch
checked={store.s3ForcePathStyle}
onChange={v => store.s3ForcePathStyle = v}
/>
</SettingsSection>
</>
<SettingSwitch
name="Use Path-Style Endpoint"
description="Use endpoint/bucket/key format, recommended for R2."
checked={store.s3ForcePathStyle}
onChange={v => store.s3ForcePathStyle = v}
/>
</SettingGroup>
)}
{isCatbox && (
<SettingTextInput
name="Catbox Userhash"
description="Your Catbox userhash for account binding (leave empty for anonymous uploads)"
value={store.catboxUserhash}
onChange={v => store.catboxUserhash = v}
placeholder="Your Catbox userhash"
/>
<SettingGroup name="Catbox" description="Optional account binding for Catbox uploads.">
<SettingTextInput
name="Catbox Userhash"
description="Your Catbox userhash for account binding, leave empty for anonymous uploads."
value={store.catboxUserhash}
onChange={v => store.catboxUserhash = v}
placeholder="Your Catbox userhash"
/>
</SettingGroup>
)}
{isLitterbox && (
@ -565,37 +622,43 @@ export function SettingsComponent() {
)}
{isGofile && (
<SettingTextInput
name="GoFile Token"
description="Optional GoFile token to upload into your account"
value={store.gofileToken}
onChange={v => store.gofileToken = v}
placeholder="Optional GoFile token"
/>
<SettingGroup name="GoFile" description="Optional account binding for GoFile uploads.">
<SettingTextInput
name="GoFile Token"
description="Optional GoFile token to upload into your account."
value={store.gofileToken}
onChange={v => store.gofileToken = v}
placeholder="Optional GoFile token"
/>
</SettingGroup>
)}
{isPixelVault && (
<SettingTextInput
name="PixelVault Upload Key"
description="Your PixelVault authorization key"
value={store.pixelVaultKey}
onChange={v => store.pixelVaultKey = v}
placeholder="Your PixelVault upload key"
/>
<SettingGroup name="PixelVault" description="Connection details for PixelVault uploads.">
<SettingTextInput
name="PixelVault Upload Key"
description="Your PixelVault authorization key."
value={store.pixelVaultKey}
onChange={v => store.pixelVaultKey = v}
placeholder="Your PixelVault upload key"
/>
</SettingGroup>
)}
{isPixelDrain && (
<SettingTextInput
name="PixelDrain API Key"
description="Optional PixelDrain API key for authenticated uploads. Leave empty for anonymous uploads."
value={store.pixelDrainKey}
onChange={v => store.pixelDrainKey = v}
placeholder="Your PixelDrain API key"
/>
<SettingGroup name="PixelDrain" description="Optional account binding for PixelDrain uploads.">
<SettingTextInput
name="PixelDrain API Key"
description="Optional PixelDrain API key for authenticated uploads. Leave empty for anonymous uploads."
value={store.pixelDrainKey}
onChange={v => store.pixelDrainKey = v}
placeholder="Your PixelDrain API key"
/>
</SettingGroup>
)}
{isShareX && (
<>
<SettingGroup name="ShareX Custom Uploader" description="Paste, import, or validate a ShareX custom uploader config.">
<SettingsSection
name="ShareX Custom Uploader Config"
description="Paste your ShareX custom uploader JSON (.sxcu/.json). DestinationType must include FileUploader or ImageUploader."
@ -608,7 +671,7 @@ export function SettingsComponent() {
/>
</SettingsSection>
<SettingsSection name="ShareX Config Actions" description="Import from file or validate pasted config">
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<div className={cl("actions")}>
<Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button>
<Button size="small" onClick={validateShareXConfig}>Validate</Button>
</div>
@ -620,134 +683,140 @@ export function SettingsComponent() {
onChange={handleShareXFileUpload}
/>
</SettingsSection>
</>
</SettingGroup>
)}
<SettingsSection tag="label" name="Strip Query Parameters" description="Strip query parameters from the uploaded file URL" inlineSetting>
<Switch
<SettingGroup name="Upload Behavior" description="Control what FileUpload does after a host returns a URL.">
<SettingSwitch
name="Strip Query Parameters"
description="Strip query parameters from the uploaded file URL."
checked={store.stripQueryParams}
onChange={v => store.stripQueryParams = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Use Embed Proxy" description="Wrap uploaded video links with an embed proxy service for better Discord previews" inlineSetting>
<Switch
<SettingSwitch
name="Use Embed Proxy"
description="Wrap uploaded video links with an embed proxy service for better Discord previews."
checked={store.embedProxyEnabled}
onChange={v => {
store.embedProxyEnabled = v;
update();
}}
/>
</SettingsSection>
{store.embedProxyEnabled && (
<SettingsSection name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links">
<Select
options={embedProxyOptions}
isSelected={v => v === store.embedProxyService}
select={v => {
store.embedProxyService = v;
update();
}}
serialize={v => v}
placeholder="Select an embed proxy service"
/>
</SettingsSection>
)}
{store.embedProxyEnabled && (
<SettingsSection name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links">
<Select
options={embedProxyOptions}
isSelected={v => v === store.embedProxyService}
select={v => {
store.embedProxyService = v;
update();
}}
serialize={v => v}
placeholder="Select an embed proxy service"
/>
</SettingsSection>
)}
<SettingTextInput
name="CORS Proxy URL"
description="CORS proxy used for web uploads. Leave empty to use the default proxy"
value={store.corsProxyUrl || ""}
onChange={v => store.corsProxyUrl = v}
placeholder="https://your-cors-proxy.example.com"
/>
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
</SettingsSection>
<SettingsSection tag="label" name="Convert APNG to GIF" description="Convert APNG files to GIF format" inlineSetting>
<Switch
<SettingSwitch
name="Convert APNG to GIF"
description="Convert APNG files to GIF format."
checked={store.apngToGif}
onChange={v => store.apngToGif = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Preserve Original Filename" description="Use the original filename instead of naming uploads as upload.ext" inlineSetting>
<Switch
<SettingSwitch
name="Preserve Original Filename"
description="Use the original filename instead of naming uploads as upload.ext."
checked={Boolean((store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename)}
onChange={v => (store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Auto Copy URL" description="Automatically copy the uploaded file URL to clipboard" inlineSetting>
<Switch
<SettingSwitch
name="Auto Copy URL"
description="Automatically copy the uploaded file URL to clipboard."
checked={store.autoCopy}
onChange={v => store.autoCopy = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Disable Fallback Uploaders" description="Only use the selected uploader without trying fallback hosts" inlineSetting>
<Switch
<SettingSwitch
name="Disable Fallback Uploaders"
description="Only use the selected uploader without trying fallback hosts."
checked={store.disableFallbacks}
onChange={v => store.disableFallbacks = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Insert URL into Chat Input" description="After upload, insert the resulting URL into the current chat input" inlineSetting>
<Switch
<SettingSwitch
name="Insert URL into Chat Input"
description="After upload, insert the resulting URL into the current chat input."
checked={store.autoSend}
onChange={v => store.autoSend = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Format Inserted URL" description="Wrap inserted URLs in angle brackets to avoid Discord preview embedding" inlineSetting>
<Switch
<SettingSwitch
name="Format Inserted URL"
description="Wrap inserted URLs in angle brackets to avoid Discord preview embedding."
checked={store.autoFormat}
onChange={v => store.autoFormat = v}
/>
</SettingsSection>
</SettingGroup>
<SettingsSection tag="label" name="Bypass Discord Upload Button" description="Use FileUpload when uploading through Discord's file picker" inlineSetting>
<Switch
<SettingGroup name="Discord Integration" description="Choose when FileUpload takes over Discord file handling.">
<SettingSwitch
name="Bypass Discord Upload Button"
description="Use FileUpload when uploading through Discord's file picker."
checked={Boolean((store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload)}
onChange={v => (store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Auto Upload Pasted Files" description="Automatically upload files from clipboard to image host when pasting in chat input" inlineSetting>
<Switch
<SettingSwitch
name="Auto Upload Pasted Files"
description="Automatically upload files from clipboard to image host when pasting in chat input."
checked={store.autoUploadPastedFiles}
onChange={v => store.autoUploadPastedFiles = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Respect Discord File Size Limit" description="Use FileUpload only for files larger than your current Discord upload limit" inlineSetting>
<Switch
<SettingSwitch
name="Respect Discord File Size Limit"
description="Use FileUpload only for files larger than your current Discord upload limit."
checked={store.bypassDiscordUploadOnlyOverLimit}
onChange={v => store.bypassDiscordUploadOnlyOverLimit = v}
/>
</SettingsSection>
</SettingGroup>
<SettingsSection name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback">
<Select
options={[
{ label: "30 seconds", value: 30000 },
{ label: "1 minute", value: 60000 },
{ label: "2 minutes", value: 120000 },
{ label: "5 minutes", value: 300000, default: true },
{ label: "10 minutes", value: 600000 }
]}
isSelected={v => v === (store.uploadTimeoutMs || 300000)}
select={v => {
store.uploadTimeoutMs = v;
update();
}}
serialize={v => v}
placeholder="Select timeout"
<SettingGroup name="Network" description="Configure browser upload proxying and timeouts.">
<SettingTextInput
name="CORS Proxy URL"
description="CORS proxy used for web uploads. Leave empty to use the default proxy."
value={store.corsProxyUrl || ""}
onChange={v => store.corsProxyUrl = v}
placeholder="https://your-cors-proxy.example.com"
/>
</SettingsSection>
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
</SettingsSection>
<SettingsSection name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback">
<Select
options={[
{ label: "30 seconds", value: 30000 },
{ label: "1 minute", value: 60000 },
{ label: "2 minutes", value: 120000 },
{ label: "5 minutes", value: 300000, default: true },
{ label: "10 minutes", value: 600000 }
]}
isSelected={v => v === (store.uploadTimeoutMs || 300000)}
select={v => {
store.uploadTimeoutMs = v;
update();
}}
serialize={v => v}
placeholder="Select timeout"
/>
</SettingsSection>
</SettingGroup>
<FallbackOrderSettings />
</>

View file

@ -32,6 +32,12 @@
color: var(--text-muted);
}
.vc-file-upload-progress-percent {
font-size: 12px;
color: var(--text-default);
white-space: nowrap;
}
.vc-file-upload-progress-cancel {
border: none;
border-radius: 6px;
@ -106,3 +112,45 @@
.vc-file-upload-settings-fallback-order-actions {
margin-top: 8px;
}
.vc-file-upload-settings-group {
display: grid;
gap: 10px;
}
.vc-file-upload-settings-service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.vc-file-upload-settings-service-option {
min-height: 38px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--background-secondary);
color: var(--text-default);
cursor: pointer;
padding: 8px 10px;
text-align: left;
}
.vc-file-upload-settings-service-option:hover {
background: var(--background-modifier-hover);
}
.vc-file-upload-settings-service-option[data-selected="true"] {
border-color: var(--brand-500);
background: var(--background-modifier-selected);
}
.vc-file-upload-settings-service-option-label {
font-size: 13px;
font-weight: 500;
}
.vc-file-upload-settings-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

View file

@ -21,6 +21,14 @@ type S3Store = {
corsProxyUrl?: string;
};
type UploadRequestResponse = {
ok: boolean;
status: number;
text(): Promise<string>;
};
type UploadRequest = (url: string, options: RequestInit) => Promise<UploadRequestResponse>;
const textEncoder = new TextEncoder();
function toArrayBuffer(data: ArrayBuffer | Uint8Array | string): ArrayBuffer {
@ -90,7 +98,8 @@ export function isS3Configured(): boolean {
export async function uploadToS3(
fileBlob: Blob,
filename: string,
native: PluginNative<typeof import("../native")> | null
native: PluginNative<typeof import("../native")> | null,
uploadRequest: UploadRequest
): Promise<string> {
const {
s3Endpoint,
@ -189,7 +198,7 @@ export async function uploadToS3(
throw new Error(result.error || "Upload failed");
}
} else {
const response = await fetch(toProxiedUrl(uploadUrl.toString(), settings.store.corsProxyUrl), {
const response = await uploadRequest(toProxiedUrl(uploadUrl.toString(), settings.store.corsProxyUrl), {
method: "PUT",
headers: requestHeaders,
body: fileBlob

View file

@ -48,6 +48,8 @@ export interface UploadProgressState {
attempt: number;
totalAttempts: number;
percent: number;
transferredBytes: number;
totalBytes: number;
status: string;
canCancel: boolean;
}
@ -60,6 +62,8 @@ const defaultUploadState: UploadProgressState = {
attempt: 0,
totalAttempts: 0,
percent: 0,
transferredBytes: 0,
totalBytes: 0,
status: "",
canCancel: false
};
@ -67,6 +71,7 @@ const defaultUploadState: UploadProgressState = {
let uploadState: UploadProgressState = { ...defaultUploadState };
const uploadStateListeners = new Set<() => void>();
let activeAbortController: AbortController | null = null;
let activeXhr: XMLHttpRequest | null = null;
let cancelRequested = false;
function isUploadCancelledError(error: unknown): boolean {
@ -121,6 +126,7 @@ export function cancelCurrentUpload() {
cancelRequested = true;
activeAbortController?.abort();
activeXhr?.abort();
setUploadState({
phase: "cancelled",
status: "Upload cancelled.",
@ -163,6 +169,103 @@ async function fetchWithTimeout(url: string, options: RequestInit): Promise<Resp
}
}
function getHeaderEntries(headers?: HeadersInit): [string, string][] {
if (!headers) return [];
if (headers instanceof Headers) return Array.from(headers.entries());
if (Array.isArray(headers)) return headers.map(([key, value]) => [key, value]);
return Object.entries(headers);
}
class XhrResponse {
ok: boolean;
headers: Headers;
status: number;
statusText: string;
url: string;
constructor(private xhr: XMLHttpRequest) {
this.status = xhr.status;
this.statusText = xhr.statusText;
this.url = xhr.responseURL;
this.ok = this.status >= 200 && this.status < 300;
this.headers = new Headers();
const rawHeaders = xhr.getAllResponseHeaders();
for (const line of rawHeaders.trim().split(/[\r\n]+/)) {
if (!line) continue;
const separatorIndex = line.indexOf(":");
if (separatorIndex < 0) continue;
this.headers.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim());
}
}
async text(): Promise<string> {
return typeof this.xhr.response === "string"
? this.xhr.response
: this.xhr.responseText;
}
async json(): Promise<unknown> {
return JSON.parse(await this.text());
}
}
function setXhrUploadProgress(event: ProgressEvent) {
if (!event.lengthComputable || event.total <= 0) {
setUploadState({
status: uploadState.currentServiceLabel
? `Uploading via ${uploadState.currentServiceLabel}...`
: "Uploading..."
});
return;
}
const percent = Math.round(Math.max(0, Math.min(100, event.loaded / event.total * 100)));
setUploadState({
phase: "uploading",
percent,
transferredBytes: event.loaded,
totalBytes: event.total,
status: uploadState.currentServiceLabel
? `Uploading via ${uploadState.currentServiceLabel}...`
: "Uploading..."
});
}
async function uploadRequestWithTimeout(url: string, options: RequestInit): Promise<XhrResponse> {
const requestUrl = toProxyUrl(url);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
activeXhr = xhr;
const timeout = setTimeout(() => xhr.abort(), getUploadTimeoutMs());
xhr.open(options.method || "GET", requestUrl);
for (const [key, value] of getHeaderEntries(options.headers)) {
xhr.setRequestHeader(key, value);
}
xhr.upload.onprogress = setXhrUploadProgress;
xhr.onload = () => resolve(new XhrResponse(xhr));
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.onabort = () => reject(new Error(cancelRequested ? "Upload cancelled by user" : "Upload timed out"));
xhr.onloadend = () => {
clearTimeout(timeout);
xhr.upload.onprogress = null;
if (activeXhr === xhr) {
activeXhr = null;
}
};
const { body } = options;
xhr.send(body instanceof ReadableStream ? null : body as XMLHttpRequestBodyInit | null);
});
}
function resolveShareXRequestValue(value: string | number | boolean, filename: string): string {
return String(value)
.replace(/\$filename\$/g, filename)
@ -225,7 +328,7 @@ async function uploadToShareX(fileBlob: Blob, filename: string): Promise<string>
throw new Error(`Unsupported ShareX Body type: ${config.Body || "unknown"}`);
}
const response = await fetchWithTimeout(requestUrl, { method, headers, body });
const response = await uploadRequestWithTimeout(requestUrl, { method, headers, body });
const responseText = await response.text();
let responseJson: unknown = null;
@ -272,7 +375,7 @@ async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string
headers["x-zipline-folder"] = folderId;
}
const response = await fetchWithTimeout(`${baseUrl}/api/upload`, {
const response = await uploadRequestWithTimeout(`${baseUrl}/api/upload`, {
method: "POST",
headers,
body: formData
@ -289,7 +392,7 @@ async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string
throw new Error("Server returned invalid response (not JSON)");
}
const data: UploadResponse = await response.json();
const data = await response.json() as UploadResponse;
if (data.files && data.files.length > 0 && data.files[0].url) {
return data.files[0].url;
@ -323,7 +426,7 @@ async function uploadToNest(fileBlob: Blob, filename: string): Promise<string> {
const formData = new FormData();
formData.append("file", fileBlob, filename);
const response = await fetchWithTimeout("https://nest.rip/api/files/upload", {
const response = await uploadRequestWithTimeout("https://nest.rip/api/files/upload", {
method: "POST",
headers: {
"Authorization": nestToken
@ -416,7 +519,7 @@ async function uploadToEzHost(fileBlob: Blob, filename: string): Promise<string>
const headers: Record<string, string> = { key: ezHostKey };
const response = await fetchWithTimeout("https://api.e-z.host/files", {
const response = await uploadRequestWithTimeout("https://api.e-z.host/files", {
method: "POST",
headers,
body: formData
@ -427,12 +530,14 @@ async function uploadToEzHost(fileBlob: Blob, filename: string): Promise<string>
throw new Error(`Upload failed: ${response.status} ${text}`);
}
const data = await response.json();
const data = await response.json() as { success?: boolean; error?: string; imageUrl?: string; rawUrl?: string; };
if (!data || !data.success) {
throw new Error(data?.error || "Upload failed");
}
return data.imageUrl || data.rawUrl;
const url = data.imageUrl || data.rawUrl;
if (!url) throw new Error("No URL returned from upload");
return url;
}
async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string> {
@ -449,7 +554,7 @@ async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string>
if (catboxUserhash) formData.append("userhash", catboxUserhash);
formData.append("fileToUpload", fileBlob, filename);
const response = await fetchWithTimeout("https://catbox.moe/user/api.php", {
const response = await uploadRequestWithTimeout("https://catbox.moe/user/api.php", {
method: "POST",
body: formData
});
@ -493,7 +598,7 @@ async function uploadToLitterbox(fileBlob: Blob, filename: string): Promise<stri
formData.append("time", expiry);
formData.append("fileToUpload", fileBlob, filename);
const response = await fetchWithTimeout("https://litterbox.catbox.moe/resources/internals/api.php", {
const response = await uploadRequestWithTimeout("https://litterbox.catbox.moe/resources/internals/api.php", {
method: "POST",
body: formData
});
@ -520,7 +625,7 @@ async function uploadToGofile(fileBlob: Blob, filename: string): Promise<string>
formData.append("file", fileBlob, filename);
const uploadUrl = "https://upload.gofile.io/uploadfile";
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "POST",
body: formData
});
@ -555,7 +660,7 @@ async function uploadToTmpfiles(fileBlob: Blob, filename: string): Promise<strin
formData.append("file", fileBlob, filename);
const uploadUrl = "https://tmpfiles.org/api/v1/upload";
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "POST",
body: formData
});
@ -583,7 +688,7 @@ async function uploadToBuzzheavier(fileBlob: Blob, filename: string): Promise<st
}
const uploadUrl = `https://w.buzzheavier.com/${encodeURIComponent(filename)}`;
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "PUT",
body: fileBlob
});
@ -618,7 +723,7 @@ async function uploadToTempSh(fileBlob: Blob, filename: string): Promise<string>
formData.append("file", fileBlob, filename);
const uploadUrl = "https://temp.sh/upload";
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "POST",
body: formData
});
@ -650,7 +755,7 @@ async function uploadToFilebin(fileBlob: Blob, filename: string): Promise<string
const formData = new FormData();
formData.append("file", fileBlob, filename);
const response = await fetchWithTimeout(uploadUrl, {
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "POST",
body: formData
});
@ -686,7 +791,7 @@ async function uploadToPixelVault(fileBlob: Blob, filename: string): Promise<str
const formData = new FormData();
formData.append("file", fileBlob, filename);
const response = await fetchWithTimeout("https://pixelvault.co/", {
const response = await uploadRequestWithTimeout("https://pixelvault.co/", {
method: "POST",
headers: {
Authorization: pixelVaultKey.trim()
@ -729,7 +834,7 @@ async function uploadToPixelDrain(fileBlob: Blob, filename: string): Promise<str
headers.Authorization = `Basic ${btoa(`:${pixelDrainKey.trim()}`)}`;
}
const response = await fetchWithTimeout(`https://pixeldrain.com/api/file/${encodeURIComponent(filename)}`, {
const response = await uploadRequestWithTimeout(`https://pixeldrain.com/api/file/${encodeURIComponent(filename)}`, {
method: "PUT",
headers,
body: fileBlob
@ -763,7 +868,7 @@ async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filenam
case ServiceType.EZHOST:
return uploadToEzHost(fileBlob, filename);
case ServiceType.S3:
return uploadToS3(fileBlob, filename, Native);
return uploadToS3(fileBlob, filename, Native, uploadRequestWithTimeout);
case ServiceType.CATBOX:
return uploadToCatbox(fileBlob, filename);
case ServiceType.ZEROX0:
@ -940,6 +1045,8 @@ async function uploadWithFallbacks(fileBlob: Blob, filename: string, primary: Se
totalAttempts: uploadOrder.length,
attempt: 1,
percent: 5,
transferredBytes: 0,
totalBytes: fileBlob.size,
status: `Starting upload via ${serviceLabels[uploadOrder[0]]}...`,
currentService: uploadOrder[0],
currentServiceLabel: serviceLabels[uploadOrder[0]],
@ -950,12 +1057,15 @@ async function uploadWithFallbacks(fileBlob: Blob, filename: string, primary: Se
if (cancelRequested) throw new Error("Upload cancelled by user");
const attempt = attempted.length + 1;
setUploadState({
phase: attempt === 1 ? "uploading" : "retrying",
attempt,
currentService: service,
currentServiceLabel: serviceLabels[service],
percent: Math.min(90, 10 + Math.round((attempt / uploadOrder.length) * 70)),
transferredBytes: 0,
totalBytes: fileBlob.size,
percent: 0,
status: attempt === 1
? `Uploading via ${serviceLabels[service]}...`
: `Retrying with ${serviceLabels[service]} (${attempt}/${uploadOrder.length})...`
@ -1134,6 +1244,7 @@ export async function uploadFile(url: string): Promise<void> {
} finally {
isUploading = false;
activeAbortController = null;
activeXhr = null;
setTimeout(() => resetUploadState(), 1800);
}
}
@ -1197,6 +1308,7 @@ export async function uploadProvidedFiles(files: readonly File[]): Promise<void>
} finally {
isUploading = false;
activeAbortController = null;
activeXhr = null;
setTimeout(() => resetUploadState(), 1800);
}
}

View file

@ -8,8 +8,7 @@ import * as DataStore from "@api/DataStore";
import { removeRecentStickerByPackId } from "./components";
import { DynamicPackSetMeta, DynamicStickerPackMeta, StickerPack, StickerPackMeta } from "./types";
import { Mutex } from "./utils";
import { corsFetch } from "./utils";
import { corsFetch, Mutex } from "./utils";
const mutex = new Mutex();