feat: show file uploads as a toast like every other notice

Uploads in Files had their own card with a thick progress bar, larger
corners and a heavier shadow, so they looked unlike the toasts around
them.

Toasts can now show work in progress: the thin line at the bottom fills
with progress instead of counting down, and the toast stays until it is
updated or removed. Uploads use it, with the file and percentage below
the title and Cancel as the toast action.
This commit is contained in:
Linus Rath
2026-10-11 16:38:05 +02:00
parent 87915f8bf1
commit cd5fe770e4
5 changed files with 148 additions and 43 deletions
+32 -37
View File
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import {
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
RefreshCw, Star, StarOff, Plus, X, CheckSquare,
RefreshCw, Star, StarOff, Plus, CheckSquare,
} from "@/components/icons";
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -27,6 +27,7 @@ import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } fro
import { ResizeHandle } from "@/components/layout/resize-handle";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import { useFileStore, type FileResource } from "@/stores/file-store";
import { toast } from "@/stores/toast-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNodeRights } from "@/lib/jmap/types";
@@ -266,6 +267,36 @@ export function FileBrowser({
if (scrollAreaRef.current) scrollAreaRef.current.scrollTop = 0;
}, [currentPath]);
// ---- Upload progress, shown as a toast like every other notice ----------------
const uploadToastId = useRef<string | null>(null);
const cancelUploadRef = useRef(onCancelUpload);
cancelUploadRef.current = onCancelUpload;
useEffect(() => {
if (!uploadProgress) {
if (uploadToastId.current) toast.dismiss(uploadToastId.current);
uploadToastId.current = null;
return;
}
const progress = uploadProgress.total > 0 ? uploadProgress.loaded / uploadProgress.total : null;
const title = uploadProgress.totalFiles > 1
? t("uploading_count", { current: uploadProgress.current, total: uploadProgress.totalFiles })
: t("uploading");
const message = progress === null ? uploadProgress.name : `${uploadProgress.name} · ${Math.round(progress * 100)}%`;
if (uploadToastId.current) {
// Closing the toast only hides it; the upload carries on.
toast.update(uploadToastId.current, { title, message, progress });
} else {
uploadToastId.current = toast.progress(title, {
message,
progress,
action: { label: t("cancel"), onClick: () => cancelUploadRef.current() },
});
}
}, [uploadProgress, t]);
useEffect(() => () => {
if (uploadToastId.current) toast.dismiss(uploadToastId.current);
}, []);
// ---- Sharing ---------------------------------------------------------------
// A node is shareable when the server supports JMAP Sharing, the viewer owns
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
@@ -1056,13 +1087,6 @@ export function FileBrowser({
</Button>
)}
{uploadProgress && (
<UploadProgressCard
progress={uploadProgress}
onCancel={onCancelUpload}
className={cn("absolute end-4 z-30 w-80 max-w-[calc(100%-2rem)]", isNarrow && canCreate ? "bottom-20" : "bottom-4")}
/>
)}
</div>
</div>
@@ -1183,35 +1207,6 @@ function SharedWithMe({ folders, onOpen, iconOptions }: {
);
}
function UploadProgressCard({ progress, onCancel, className }: {
progress: { name: string; loaded: number; total: number; current: number; totalFiles: number };
onCancel: () => void;
className?: string;
}) {
const t = useTranslations("files");
const percent = progress.total > 0 ? Math.round((progress.loaded / progress.total) * 100) : null;
return (
<div role="status" className={cn("rounded-lg border border-border bg-background shadow-lg p-3", className)}>
<div className="flex items-center gap-2">
<Upload className="w-4 h-4 text-primary shrink-0" />
<p className="text-sm font-medium flex-1 min-w-0 truncate">
{progress.totalFiles > 1
? t("uploading_count", { current: progress.current, total: progress.totalFiles })
: t("uploading")}
</p>
<span className="text-xs text-muted-foreground tabular-nums">{percent === null ? "…" : `${percent}%`}</span>
<Button variant="ghost" size="icon" className="h-6 w-6 -me-1" onClick={onCancel} aria-label={t("cancel_upload")} title={t("cancel_upload")}>
<X className="w-3.5 h-3.5" />
</Button>
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5 ms-6">{progress.name}</p>
<div className="mt-2 h-1.5 bg-primary/15 rounded-full overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent ?? undefined}>
<div className="h-full bg-primary rounded-full transition-all duration-300" style={{ width: `${percent ?? 0}%` }} />
</div>
</div>
);
}
function ListSkeleton() {
return (
<div className="px-4" aria-hidden>
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { ToastContainer } from '../toast';
import { toast, useToastStore } from '@/stores/toast-store';
function Toasts() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
return <ToastContainer toasts={toasts} onClose={removeToast} />;
}
describe('progress toasts', () => {
beforeEach(() => {
vi.useFakeTimers();
useToastStore.setState({ toasts: [] });
});
afterEach(() => {
vi.useRealTimers();
});
it('shows progress on the bottom line and stays until removed', () => {
render(<Toasts />);
let id = '';
act(() => { id = toast.progress('Uploading...', { message: 'a.stl', progress: 0.25 }); });
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25');
act(() => { vi.advanceTimersByTime(60_000); });
expect(screen.getByText('Uploading...')).toBeInTheDocument();
act(() => { toast.update(id, { progress: 0.8, message: 'a.stl · 80%' }); });
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '80');
expect(screen.getByText('a.stl · 80%')).toBeInTheDocument();
act(() => { toast.dismiss(id); });
expect(screen.queryByText('Uploading...')).toBeNull();
});
it('runs the action, e.g. Cancel, from the toast', () => {
const onCancel = vi.fn();
render(<Toasts />);
act(() => { toast.progress('Uploading...', { progress: null, action: { label: 'Cancel', onClick: onCancel } }); });
act(() => { screen.getByText('Cancel').click(); });
expect(onCancel).toHaveBeenCalled();
});
it('counts down normally once turned into a regular toast', () => {
render(<Toasts />);
let id = '';
act(() => { id = toast.progress('Uploading...', { progress: 0.5 }); });
act(() => { toast.update(id, { title: 'Uploaded', progress: undefined, duration: 1000, type: 'success' }); });
expect(screen.getByText('Uploaded')).toBeInTheDocument();
act(() => { vi.advanceTimersByTime(1500); });
expect(useToastStore.getState().toasts).toHaveLength(0);
});
});
+36 -4
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "@/components/icons";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
import { cn } from "@/lib/utils";
export type ToastType = "success" | "error" | "info" | "warning";
@@ -27,6 +27,12 @@ export interface Toast {
* that has one lays its actions out on a row of their own.
*/
tertiaryAction?: ToastAction;
/**
* Work in progress, e.g. an upload: 0..1, or null while the amount is
* unknown. The bottom line then shows progress instead of a countdown,
* and the toast stays until it is updated or removed.
*/
progress?: number | null;
}
interface ToastProps {
@@ -64,6 +70,14 @@ export function ToastItem({ toast, onClose }: ToastProps) {
const remainingRef = useRef(toast.duration ?? 5000);
const startRef = useRef(Date.now());
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isProgress = toast.progress !== undefined;
// A toast updated in place (a finished upload) starts a fresh countdown.
const lastDurationRef = useRef(toast.duration);
if (lastDurationRef.current !== toast.duration) {
lastDurationRef.current = toast.duration;
remainingRef.current = toast.duration ?? 5000;
}
const dismiss = useCallback(() => {
setExiting(true);
@@ -71,7 +85,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
}, [onClose, toast.id]);
useEffect(() => {
if (!toast.duration || toast.duration <= 0) return;
if (!toast.duration || toast.duration <= 0 || isProgress) return;
if (paused) {
if (timerRef.current) clearTimeout(timerRef.current);
@@ -84,7 +98,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [toast.duration, paused, dismiss]);
}, [toast.duration, paused, dismiss, isProgress]);
const actionButton = (action: ToastAction) => (
<button
@@ -128,6 +142,8 @@ export function ToastItem({ toast, onClose }: ToastProps) {
{/* Icon */}
{toast.icon !== undefined ? (
toast.icon
) : isProgress ? (
<Loader2 className="w-[18px] h-[18px] flex-shrink-0 mt-px text-muted-foreground animate-spin" />
) : (
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0 mt-px", iconStyles[toast.type])} />
)}
@@ -169,8 +185,24 @@ export function ToastItem({ toast, onClose }: ToastProps) {
<X className="w-3.5 h-3.5" />
</button>
{/* Progress line: the same line as the countdown, filled by the work done */}
{isProgress && (
<div
className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/[0.06]"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={toast.progress == null ? undefined : Math.round(toast.progress * 100)}
>
<div
className={cn("h-full opacity-60 transition-[width] duration-300", progressBarStyles[toast.type], toast.progress == null && "w-1/3 animate-pulse")}
style={toast.progress == null ? undefined : { width: `${Math.max(0, Math.min(1, toast.progress)) * 100}%` }}
/>
</div>
)}
{/* Countdown line */}
{toast.duration && toast.duration > 0 && (
{!isProgress && !!toast.duration && toast.duration > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/[0.06]">
<div
className={cn("h-full opacity-60", progressBarStyles[toast.type])}
-1
View File
@@ -3720,7 +3720,6 @@
"choose_account": "Choose an account",
"drop_to_upload": "Drop to upload to {folder}",
"uploading_count": "Uploading {current} of {total}",
"cancel_upload": "Cancel upload",
"dismiss": "Dismiss",
"zoom_in": "Zoom in",
"zoom_out": "Zoom out",
+25 -1
View File
@@ -4,7 +4,9 @@ import { generateUUID } from "@/lib/utils";
interface ToastStore {
toasts: Toast[];
addToast: (toast: Omit<Toast, "id">) => void;
addToast: (toast: Omit<Toast, "id">) => string;
/** Change a shown toast in place, e.g. to move a progress toast along. */
updateToast: (id: string, patch: Partial<Omit<Toast, "id">>) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
}
@@ -27,6 +29,13 @@ export const useToastStore = create<ToastStore>((set) => ({
set((state) => ({
toasts: [...state.toasts, newToast],
}));
return id;
},
updateToast: (id, patch) => {
set((state) => ({
toasts: state.toasts.map((t) => (t.id === id ? { ...t, ...patch } : t)),
}));
},
removeToast: (id) => {
@@ -61,7 +70,22 @@ function showToast(type: Toast["type"], title: string, options?: string | ToastO
});
}
interface ProgressToastOptions {
message?: string;
/** 0..1, or null while unknown. */
progress: number | null;
action?: ToastAction;
}
export const toast = {
/**
* A toast for work in progress. It stays until updated or dismissed; move
* it along with `toast.update(id, { progress, message })`.
*/
progress: (title: string, options: ProgressToastOptions): string =>
useToastStore.getState().addToast({ type: "info", title, duration: 0, ...options }),
update: (id: string, patch: Partial<Omit<Toast, "id">>) => useToastStore.getState().updateToast(id, patch),
dismiss: (id: string) => useToastStore.getState().removeToast(id),
success: (title: string, options?: string | ToastOptions) => showToast("success", title, options),
error: (title: string, options?: string | ToastOptions) => showToast("error", title, options, 10000),
info: (title: string, options?: string | ToastOptions) => showToast("info", title, options),