From cd5fe770e40dbdb548fbdfff7f4296a8d2c1d320 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:38:05 +0200 Subject: [PATCH] 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. --- components/files/file-browser.tsx | 69 +++++++++---------- .../ui/__tests__/toast-progress.test.tsx | 55 +++++++++++++++ components/ui/toast.tsx | 40 +++++++++-- locales/en/common.json | 1 - stores/toast-store.ts | 26 ++++++- 5 files changed, 148 insertions(+), 43 deletions(-) create mode 100644 components/ui/__tests__/toast-progress.test.tsx diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 7b167eb73..902e58f2c 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -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(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({ )} - {uploadProgress && ( - - )} @@ -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 ( -
-
- -

- {progress.totalFiles > 1 - ? t("uploading_count", { current: progress.current, total: progress.totalFiles }) - : t("uploading")} -

- {percent === null ? "…" : `${percent}%`} - -
-

{progress.name}

-
-
-
-
- ); -} - function ListSkeleton() { return (
diff --git a/components/ui/__tests__/toast-progress.test.tsx b/components/ui/__tests__/toast-progress.test.tsx new file mode 100644 index 000000000..3eb46e0cd --- /dev/null +++ b/components/ui/__tests__/toast-progress.test.tsx @@ -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 ; +} + +describe('progress toasts', () => { + beforeEach(() => { + vi.useFakeTimers(); + useToastStore.setState({ toasts: [] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows progress on the bottom line and stays until removed', () => { + render(); + 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(); + 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(); + 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); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index fde6d04f5..011282cca 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -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 | 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) => ( + {/* Progress line: the same line as the countdown, filled by the work done */} + {isProgress && ( +
+
+
+ )} + {/* Countdown line */} - {toast.duration && toast.duration > 0 && ( + {!isProgress && !!toast.duration && toast.duration > 0 && (
) => void; + addToast: (toast: Omit) => string; + /** Change a shown toast in place, e.g. to move a progress toast along. */ + updateToast: (id: string, patch: Partial>) => void; removeToast: (id: string) => void; clearToasts: () => void; } @@ -27,6 +29,13 @@ export const useToastStore = create((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>) => 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),