From 9a5e613331a7980749b6eca60a91df52fcdbfc77 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:51:56 +0100 Subject: [PATCH] feat: add upload progress bars and error toasts Adds uploadWithProgress() using XMLHttpRequest for real-time upload progress events. MessageInput now shows per-file progress bars with percentage overlay during upload. Failed uploads show toast warnings with the specific error instead of silently failing. Upload timeout raised to 10 minutes for large files. --- packages/web/src/api/client.ts | 40 +++++++ .../web/src/components/chat/MessageInput.tsx | 102 ++++++++++++------ 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 6522913c..595c889d 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -128,6 +128,7 @@ export class BackspaceApiClient { readonly uploads: { upload: (file: File) => Promise; + uploadWithProgress: (file: File, onProgress: (loaded: number, total: number) => void) => Promise; url: (filename: string) => string; }; @@ -313,6 +314,44 @@ export class BackspaceApiClient { return response.json() as Promise; } + function uploadFileWithProgress(file: File, onProgress: (loaded: number, total: number) => void): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append('file', file); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) onProgress(e.loaded, e.total); + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { resolve(JSON.parse(xhr.responseText)); } + catch { reject(new Error('Invalid server response')); } + } else if (xhr.status === 401 && onUnauthorized) { + onUnauthorized(); + reject(new Error('Unauthorized')); + } else if (xhr.status === 413) { + reject(new Error('File too large')); + } else { + try { + const body = JSON.parse(xhr.responseText); + reject(new Error(body.error || `Upload failed (${xhr.status})`)); + } catch { reject(new Error(`Upload failed (${xhr.status})`)); } + } + }); + + xhr.addEventListener('error', () => reject(new Error('Upload failed — network error'))); + xhr.addEventListener('timeout', () => reject(new Error('Upload timed out'))); + + xhr.open('POST', `${baseUrl}/uploads`); + xhr.timeout = 10 * 60 * 1000; // 10 minutes for large files + const token = getToken(); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.send(formData); + }); + } + this.auth = { register: (data: RegisterRequest) => request('POST', '/auth/register', data, false), @@ -428,6 +467,7 @@ export class BackspaceApiClient { this.uploads = { upload: uploadFile, + uploadWithProgress: uploadFileWithProgress, url: (filename: string) => `${baseUrl}/uploads/${filename}`, }; diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index 1fd1044b..f2a346f3 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -8,6 +8,7 @@ import { InputPopover, type InputPopoverTab } from './InputPopover'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared'; import { useSettingsStore } from '../../stores/settingsStore'; +import { useUIStore } from '../../stores/uiStore'; interface MessageInputProps { channelId: string; @@ -24,6 +25,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { const [content, setContent] = useState(''); const [files, setFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState>(new Map()); + const addToast = useUIStore((s) => s.addToast); const [mentionState, setMentionState] = useState(null); const [activePopover, setActivePopover] = useState(null); const fileInputRef = useRef(null); @@ -99,13 +102,31 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { setIsUploading(true); setMentionState(null); setActivePopover(null); + setUploadProgress(new Map()); try { // Upload files first — route to the correct instance for this channel const attachmentIds: string[] = []; const uploadClient = getApiForOrigin(getChannelOrigin(channelId)); - for (const file of files) { - const attachment = await uploadClient.uploads.upload(file); - attachmentIds.push(attachment.id); + const failedFiles: string[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]!; + try { + const attachment = await uploadClient.uploads.uploadWithProgress(file, (loaded, total) => { + setUploadProgress(prev => new Map(prev).set(i, Math.round((loaded / total) * 100))); + }); + attachmentIds.push(attachment.id); + setUploadProgress(prev => new Map(prev).set(i, 100)); + } catch (err) { + failedFiles.push(file.name); + const msg = err instanceof Error ? err.message : 'Upload failed'; + addToast(`Failed to upload ${file.name}: ${msg}`, 'warning'); + } + } + + if (failedFiles.length > 0 && attachmentIds.length === 0 && !trimmed) { + // All uploads failed, no text — nothing to send + return; } await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined); @@ -124,9 +145,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { typingTimeoutRef.current = undefined; } } catch (err) { - console.error('Failed to send message:', err); + const msg = err instanceof Error ? err.message : 'Failed to send message'; + addToast(msg, 'warning'); } finally { setIsUploading(false); + setUploadProgress(new Map()); } }; @@ -349,32 +372,51 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { {/* File previews */} {files.length > 0 && (
- {files.map((file, i) => ( -
- {file.type.startsWith('image/') ? ( - {file.name} - ) : ( -
- - - - {file.name} -
- )} - -
- ))} + {files.map((file, i) => { + const progress = uploadProgress.get(i); + return ( +
+ {file.type.startsWith('image/') ? ( + {file.name} + ) : ( +
+ + + + {file.name} +
+ )} + {/* Upload progress bar */} + {progress !== undefined && progress < 100 && ( +
+
+
+ )} + {progress !== undefined && progress < 100 && ( +
+ {progress}% +
+ )} + {!isUploading && ( + + )} +
+ ); + })}
)}