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:
@@ -128,6 +128,7 @@ export class BackspaceApiClient {
|
|||||||
|
|
||||||
readonly uploads: {
|
readonly uploads: {
|
||||||
upload: (file: File) => Promise<Attachment>;
|
upload: (file: File) => Promise<Attachment>;
|
||||||
|
uploadWithProgress: (file: File, onProgress: (loaded: number, total: number) => void) => Promise<Attachment>;
|
||||||
url: (filename: string) => string;
|
url: (filename: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -313,6 +314,44 @@ export class BackspaceApiClient {
|
|||||||
return response.json() as Promise<Attachment>;
|
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 = {
|
this.auth = {
|
||||||
register: (data: RegisterRequest) =>
|
register: (data: RegisterRequest) =>
|
||||||
request<AuthResponse>('POST', '/auth/register', data, false),
|
request<AuthResponse>('POST', '/auth/register', data, false),
|
||||||
@@ -428,6 +467,7 @@ export class BackspaceApiClient {
|
|||||||
|
|
||||||
this.uploads = {
|
this.uploads = {
|
||||||
upload: uploadFile,
|
upload: uploadFile,
|
||||||
|
uploadWithProgress: uploadFileWithProgress,
|
||||||
url: (filename: string) => `${baseUrl}/uploads/${filename}`,
|
url: (filename: string) => `${baseUrl}/uploads/${filename}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { InputPopover, type InputPopoverTab } from './InputPopover';
|
|||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
||||||
import { useSettingsStore } from '../../stores/settingsStore';
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
interface MessageInputProps {
|
interface MessageInputProps {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
@@ -24,6 +25,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
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 [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||||
const [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
|
const [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -99,13 +102,31 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
setActivePopover(null);
|
setActivePopover(null);
|
||||||
|
setUploadProgress(new Map());
|
||||||
try {
|
try {
|
||||||
// Upload files first — route to the correct instance for this channel
|
// Upload files first — route to the correct instance for this channel
|
||||||
const attachmentIds: string[] = [];
|
const attachmentIds: string[] = [];
|
||||||
const uploadClient = getApiForOrigin(getChannelOrigin(channelId));
|
const uploadClient = getApiForOrigin(getChannelOrigin(channelId));
|
||||||
for (const file of files) {
|
const failedFiles: string[] = [];
|
||||||
const attachment = await uploadClient.uploads.upload(file);
|
|
||||||
|
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);
|
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);
|
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
|
||||||
@@ -124,9 +145,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
typingTimeoutRef.current = undefined;
|
typingTimeoutRef.current = undefined;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to send message:', err);
|
const msg = err instanceof Error ? err.message : 'Failed to send message';
|
||||||
|
addToast(msg, 'warning');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
setUploadProgress(new Map());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -349,8 +372,10 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
{/* File previews */}
|
{/* File previews */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="p-4 flex flex-wrap gap-4 bg-surface-channel/30">
|
<div className="p-4 flex flex-wrap gap-4 bg-surface-channel/30">
|
||||||
{files.map((file, i) => (
|
{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">
|
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/') ? (
|
{file.type.startsWith('image/') ? (
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(file)}
|
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>
|
<span className="truncate max-w-[120px] font-medium">{file.name}</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={() => removeFile(i)}
|
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"
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user