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.
This commit is contained in:
Jannis Braun
2026-03-23 02:51:56 +01:00
parent fdc8732ba7
commit 9a5e613331
2 changed files with 112 additions and 30 deletions
+40
View File
@@ -128,6 +128,7 @@ export class BackspaceApiClient {
readonly uploads: {
upload: (file: File) => Promise<Attachment>;
uploadWithProgress: (file: File, onProgress: (loaded: number, total: number) => void) => Promise<Attachment>;
url: (filename: string) => string;
};
@@ -313,6 +314,44 @@ export class BackspaceApiClient {
return response.json() as Promise<Attachment>;
}
function uploadFileWithProgress(file: File, onProgress: (loaded: number, total: number) => void): Promise<Attachment> {
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<AuthResponse>('POST', '/auth/register', data, false),
@@ -428,6 +467,7 @@ export class BackspaceApiClient {
this.uploads = {
upload: uploadFile,
uploadWithProgress: uploadFileWithProgress,
url: (filename: string) => `${baseUrl}/uploads/${filename}`,
};
@@ -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<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<Map<number, number>>(new Map());
const addToast = useUIStore((s) => s.addToast);
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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);
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,8 +372,10 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
{/* File previews */}
{files.length > 0 && (
<div className="p-4 flex flex-wrap gap-4 bg-surface-channel/30">
{files.map((file, i) => (
<div key={i} className="relative group bg-surface-channel rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-border-hard">
{files.map((file, i) => {
const progress = uploadProgress.get(i);
return (
<div key={i} className="relative group bg-surface-channel rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-border-hard overflow-hidden">
{file.type.startsWith('image/') ? (
<img
src={URL.createObjectURL(file)}
@@ -365,6 +390,21 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<span className="truncate max-w-[120px] font-medium">{file.name}</span>
</div>
)}
{/* Upload progress bar */}
{progress !== undefined && progress < 100 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/10">
<div
className="h-full bg-accent-primary transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
)}
{progress !== undefined && progress < 100 && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center rounded-lg">
<span className="text-xs font-medium text-white">{progress}%</span>
</div>
)}
{!isUploading && (
<button
onClick={() => removeFile(i)}
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
@@ -373,8 +413,10 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
</svg>
</button>
)}
</div>
))}
);
})}
</div>
)}